added reduce motion support for css animations and streaming text (#6551)
This commit is contained in:
@@ -6,7 +6,6 @@ interface TextSplitterOptions {
|
|||||||
splitTypeTypes?: ('lines' | 'words' | 'chars')[];
|
splitTypeTypes?: ('lines' | 'words' | 'chars')[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Class to split text into lines, words, and characters for animation
|
|
||||||
export class TextSplitter {
|
export class TextSplitter {
|
||||||
textElement: HTMLElement;
|
textElement: HTMLElement;
|
||||||
onResize: (() => void) | null;
|
onResize: (() => void) | null;
|
||||||
@@ -31,9 +30,7 @@ export class TextSplitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initResizeObserver() {
|
initResizeObserver() {
|
||||||
// Use a simpler approach to avoid type issues
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
// Just check the current width directly from the element
|
|
||||||
if (this.textElement) {
|
if (this.textElement) {
|
||||||
const currentWidth = Math.floor(this.textElement.getBoundingClientRect().width);
|
const currentWidth = Math.floor(this.textElement.getBoundingClientRect().width);
|
||||||
|
|
||||||
@@ -197,15 +194,11 @@ export class TextAnimator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
// Clear all timeouts
|
|
||||||
this.activeTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
|
this.activeTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
|
||||||
this.activeTimeouts = [];
|
this.activeTimeouts = [];
|
||||||
|
|
||||||
// Cancel all animations
|
|
||||||
this.activeAnimations.forEach((animation) => animation.cancel());
|
this.activeAnimations.forEach((animation) => animation.cancel());
|
||||||
this.activeAnimations = [];
|
this.activeAnimations = [];
|
||||||
|
|
||||||
// Reset text content
|
|
||||||
const chars = this.splitter.getChars();
|
const chars = this.splitter.getChars();
|
||||||
chars.forEach((char, index) => {
|
chars.forEach((char, index) => {
|
||||||
if (this.originalChars[index]) {
|
if (this.originalChars[index]) {
|
||||||
@@ -219,6 +212,10 @@ interface UseTextAnimatorProps {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prefersReducedMotion(): boolean {
|
||||||
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
export function useTextAnimator({ text }: UseTextAnimatorProps) {
|
export function useTextAnimator({ text }: UseTextAnimatorProps) {
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
const animator = useRef<TextAnimator | null>(null);
|
const animator = useRef<TextAnimator | null>(null);
|
||||||
@@ -226,22 +223,23 @@ export function useTextAnimator({ text }: UseTextAnimatorProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!elementRef.current) return;
|
if (!elementRef.current) return;
|
||||||
|
|
||||||
// Create animator
|
if (prefersReducedMotion()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
animator.current = new TextAnimator(elementRef.current);
|
animator.current = new TextAnimator(elementRef.current);
|
||||||
|
|
||||||
// Small delay to ensure content is ready
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
animator.current?.animate();
|
animator.current?.animate();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
if (animator.current) {
|
if (animator.current) {
|
||||||
animator.current.reset();
|
animator.current.reset();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [text]); // Re-run when text changes
|
}, [text]);
|
||||||
|
|
||||||
return elementRef;
|
return elementRef;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,12 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prefersReducedMotion(): boolean {
|
||||||
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REDUCED_MOTION_BATCH_INTERVAL = 1000;
|
||||||
|
|
||||||
async function streamFromResponse(
|
async function streamFromResponse(
|
||||||
stream: AsyncIterable<MessageEvent>,
|
stream: AsyncIterable<MessageEvent>,
|
||||||
initialMessages: Message[],
|
initialMessages: Message[],
|
||||||
@@ -203,6 +209,49 @@ async function streamFromResponse(
|
|||||||
sessionId: string
|
sessionId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let currentMessages = initialMessages;
|
let currentMessages = initialMessages;
|
||||||
|
const reduceMotion = prefersReducedMotion();
|
||||||
|
let latestTokenState: TokenState | null = null;
|
||||||
|
let latestChatState: ChatState = ChatState.Streaming;
|
||||||
|
let lastBatchUpdate = Date.now();
|
||||||
|
let hasPendingUpdate = false;
|
||||||
|
|
||||||
|
const flushBatchedUpdates = () => {
|
||||||
|
if (reduceMotion && hasPendingUpdate) {
|
||||||
|
if (latestTokenState) {
|
||||||
|
dispatch({ type: 'SET_TOKEN_STATE', payload: latestTokenState });
|
||||||
|
}
|
||||||
|
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
|
||||||
|
dispatch({ type: 'SET_CHAT_STATE', payload: latestChatState });
|
||||||
|
hasPendingUpdate = false;
|
||||||
|
lastBatchUpdate = Date.now();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeUpdateUI = (
|
||||||
|
tokenState: TokenState,
|
||||||
|
chatState: ChatState,
|
||||||
|
forceImmediate = false
|
||||||
|
) => {
|
||||||
|
if (!reduceMotion) {
|
||||||
|
dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState });
|
||||||
|
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
|
||||||
|
dispatch({ type: 'SET_CHAT_STATE', payload: chatState });
|
||||||
|
} else if (forceImmediate) {
|
||||||
|
dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState });
|
||||||
|
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
|
||||||
|
dispatch({ type: 'SET_CHAT_STATE', payload: chatState });
|
||||||
|
hasPendingUpdate = false;
|
||||||
|
lastBatchUpdate = Date.now();
|
||||||
|
} else {
|
||||||
|
latestTokenState = tokenState;
|
||||||
|
latestChatState = chatState;
|
||||||
|
hasPendingUpdate = true;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastBatchUpdate >= REDUCED_MOTION_BATCH_INTERVAL) {
|
||||||
|
flushBatchedUpdates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const event of stream) {
|
for await (const event of stream) {
|
||||||
@@ -221,24 +270,23 @@ async function streamFromResponse(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (hasToolConfirmation || hasElicitation) {
|
if (hasToolConfirmation || hasElicitation) {
|
||||||
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.WaitingForUserInput });
|
maybeUpdateUI(event.token_state, ChatState.WaitingForUserInput, true);
|
||||||
} else if (getCompactingMessage(msg)) {
|
} else if (getCompactingMessage(msg)) {
|
||||||
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Compacting });
|
maybeUpdateUI(event.token_state, ChatState.Compacting);
|
||||||
} else if (getThinkingMessage(msg)) {
|
} else if (getThinkingMessage(msg)) {
|
||||||
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Thinking });
|
maybeUpdateUI(event.token_state, ChatState.Thinking);
|
||||||
} else {
|
} else {
|
||||||
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Streaming });
|
maybeUpdateUI(event.token_state, ChatState.Streaming);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch({ type: 'SET_TOKEN_STATE', payload: event.token_state });
|
|
||||||
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'Error': {
|
case 'Error': {
|
||||||
|
flushBatchedUpdates();
|
||||||
onFinish('Stream error: ' + event.error);
|
onFinish('Stream error: ' + event.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'Finish': {
|
case 'Finish': {
|
||||||
|
flushBatchedUpdates();
|
||||||
onFinish();
|
onFinish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -247,7 +295,11 @@ async function streamFromResponse(
|
|||||||
}
|
}
|
||||||
case 'UpdateConversation': {
|
case 'UpdateConversation': {
|
||||||
currentMessages = event.conversation;
|
currentMessages = event.conversation;
|
||||||
dispatch({ type: 'SET_MESSAGES', payload: event.conversation });
|
if (!reduceMotion) {
|
||||||
|
dispatch({ type: 'SET_MESSAGES', payload: event.conversation });
|
||||||
|
} else {
|
||||||
|
hasPendingUpdate = true;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'Notification': {
|
case 'Notification': {
|
||||||
@@ -260,8 +312,10 @@ async function streamFromResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flushBatchedUpdates();
|
||||||
onFinish();
|
onFinish();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
flushBatchedUpdates();
|
||||||
if (error instanceof Error && error.name !== 'AbortError') {
|
if (error instanceof Error && error.name !== 'AbortError') {
|
||||||
onFinish('Stream error: ' + errorMessage(error));
|
onFinish('Stream error: ' + errorMessage(error));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,6 +347,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global reduced motion support - applies when user has system preference set */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
/* Disable all CSS animations and transitions, except for toasts which need transitions for auto-dismiss */
|
||||||
|
*:not(.Toastify *),
|
||||||
|
*:not(.Toastify *)::before,
|
||||||
|
*:not(.Toastify *)::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific animation classes that should be disabled */
|
||||||
|
.animate-shimmer,
|
||||||
|
.animate-spin,
|
||||||
|
.animate-pulse,
|
||||||
|
.animate-bounce,
|
||||||
|
[class*='animate-']:not(.Toastify *) {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page transitions */
|
||||||
|
.page-transition {
|
||||||
|
animation: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Goose icon entrance animation */
|
||||||
|
.goose-icon-entrance {
|
||||||
|
animation: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wind/rain animation in welcome logo */
|
||||||
|
[class*='animate-[wind'] {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Toast close button styling */
|
/* Toast close button styling */
|
||||||
.Toastify__close-button {
|
.Toastify__close-button {
|
||||||
color: var(--text-prominent-inverse) !important;
|
color: var(--text-prominent-inverse) !important;
|
||||||
|
|||||||
@@ -41,3 +41,12 @@
|
|||||||
animation: collapseUp 0.15s ease-out forwards;
|
animation: collapseUp 0.15s ease-out forwards;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reduced motion support */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.search-bar-enter,
|
||||||
|
.search-bar-exit {
|
||||||
|
animation: none;
|
||||||
|
max-height: var(--search-bar-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user