Use Motion for animations

This commit is contained in:
2025-10-29 19:45:48 +03:00
Unverified
parent c5d0b20350
commit d20800b681
7 changed files with 156 additions and 239 deletions
+19
View File
@@ -0,0 +1,19 @@
---
alwaysApply: true
---
When using the browser, use this information to work better:
## Login credentials
Username: test
Password: 11111
## Server URL
http://localhost:8301
## Rules
- Do NOT start the dev server yourself, it's started automatically.
If the URL doesn't work, stop and ask me to turn on the dev server.
- Don't wait, you are slow enough to keep up with the browser.
@@ -1,71 +0,0 @@
import { useEffect, useState, useRef } from "react";
import type { AnimatedPropertyProps } from "./types";
export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children, ...props }: AnimatedPropertyProps) {
const [height, setHeight] = useState("0px");
const [shouldRender, setShouldRender] = useState(!!visible);
const [isAnimating, setIsAnimating] = useState(false);
const measureRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (visible) {
setShouldRender(true);
setIsAnimating(true);
// Wait for content to render, then measure
setTimeout(() => {
if (measureRef.current) {
const contentHeight = measureRef.current.scrollHeight;
setHeight(`${contentHeight}px`);
}
// Animation complete
setTimeout(() => {
setHeight("auto");
setIsAnimating(false);
}, duration * 1000);
}, 0);
} else if (shouldRender) {
setIsAnimating(true);
if (measureRef.current) {
const contentHeight = measureRef.current.scrollHeight;
setHeight(`${contentHeight}px`);
// Force a reflow before animating to 0
requestAnimationFrame(() => {
// Read layout to ensure the previous height assignment is flushed
if (containerRef.current) {
containerRef.current.offsetHeight;
}
// Use a second frame to ensure the measured pixel height is applied before collapsing
requestAnimationFrame(() => {
setHeight("0px");
});
});
}
// Hide content after animation completes
setTimeout(() => {
setShouldRender(false);
setIsAnimating(false);
if (onFinish) {
onFinish();
}
}, duration * 1000);
}
}, [visible, shouldRender]);
return (visible || shouldRender || isAnimating) && (
<div
{...props}
ref={containerRef}
style={{
height,
transition: `height ${duration}s ease`,
overflow: "hidden",
...props.style
}}
>
<div ref={measureRef} style={{ height: "auto" }}>
{shouldRender && children}
</div>
</div>
);
}
@@ -1,41 +0,0 @@
import { useEffect, useState } from "react";
import type { AnimatedPropertyProps } from "./types";
export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, children, ...props }: AnimatedPropertyProps) {
const [opacity, setOpacity] = useState(visible ? 1 : 0);
const [shouldRender, setShouldRender] = useState(visible);
useEffect(() => {
if (visible) {
setShouldRender(true);
setOpacity(0);
// Wait for content to render, then animate in
const id = setTimeout(() => {
setOpacity(1);
}, 10);
return () => clearTimeout(id);
} else {
setOpacity(0);
const id = setTimeout(() => {
setShouldRender(false);
if (onFinish) {
onFinish();
}
}, duration * 1000);
return () => clearTimeout(id);
}
}, [visible, duration, onFinish]);
return shouldRender && (
<div
{...props}
style={{
opacity,
transition: `opacity ${duration}s ease`,
...props.style
}}
>{children}</div>
);
}
-10
View File
@@ -1,10 +0,0 @@
import type { ReactNode } from "react";
export interface BaseAnimatedPropertyProps {
visible: any;
duration?: number;
onFinish?: () => void
children?: ReactNode;
}
export type AnimatedPropertyProps = BaseAnimatedPropertyProps & React.ComponentPropsWithRef<"div">
@@ -1,9 +1,9 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "motion/react";
import { MaterialDialog } from "@/core/components/Dialog"; import { MaterialDialog } from "@/core/components/Dialog";
import { RichTextArea } from "@/core/components/RichTextArea"; import { RichTextArea } from "@/core/components/RichTextArea";
import type { Message } from "@/core/types"; import type { Message } from "@/core/types";
import Quote from "@/core/components/Quote"; import Quote from "@/core/components/Quote";
import AnimatedHeight from "@/core/components/animations/AnimatedHeight";
import { useImmer } from "use-immer"; import { useImmer } from "use-immer";
import { EmojiMenu } from "./EmojiMenu"; import { EmojiMenu } from "./EmojiMenu";
@@ -145,58 +145,82 @@ export function ChatInputWrapper(
return ( return (
<div className="chat-input-wrapper" ref={chatInputWrapperRef}> <div className="chat-input-wrapper" ref={chatInputWrapperRef}>
<form className="input-group" id="message-form" onSubmit={handleSubmit}> <form className="input-group" id="message-form" onSubmit={handleSubmit}>
<AnimatedHeight visible={editVisible} onFinish={onCloseEdit}> <AnimatePresence onExitComplete={onCloseEdit}>
{editingMessage && ( {editVisible && editingMessage && (
<div className="reply-preview contextual-preview"> <motion.div
<mdui-icon name="edit" /> initial={{ height: 0, opacity: 0 }}
<Quote className="reply-content contextual-content" background="surfaceContainer"> animate={{ height: "auto", opacity: 1 }}
<span className="reply-username">{editingMessage!.username}</span> exit={{ height: 0, opacity: 0 }}
<span className="reply-text">{editingMessage!.content}</span> transition={{ duration: 0.25 }}
</Quote> style={{ overflow: "hidden" }}
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit}></mdui-button-icon> >
</div> <div className="reply-preview contextual-preview">
)} <mdui-icon name="edit" />
</AnimatedHeight> <Quote className="reply-content contextual-content" background="surfaceContainer">
<AnimatedHeight visible={replyToVisible} onFinish={onCloseReply}> <span className="reply-username">{editingMessage!.username}</span>
{replyTo && ( <span className="reply-text">{editingMessage!.content}</span>
<div className="reply-preview contextual-preview"> </Quote>
<mdui-icon name="reply" /> <mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit}></mdui-button-icon>
<Quote className="reply-content contextual-content" background="surfaceContainer">
<span className="reply-username">{replyTo!.username}</span>
<span className="reply-text">{replyTo!.content}</span>
</Quote>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply}></mdui-button-icon>
</div>
)}
</AnimatedHeight>
<AnimatedHeight visible={attachmentsVisible} onFinish={() => setSelectedFiles([])}>
{selectedFiles.length > 0 && (
<div className="attachments-preview contextual-preview">
<mdui-icon name="attach_file" />
<div className="attachments-chips">
{selectedFiles.map((file, i) => (
<mdui-chip
key={i}
variant="input"
end-icon="close"
title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`}
onClick={() => {
if (selectedFiles.length == 1) {
setAttachmentsVisible(false);
} else {
setSelectedFiles(draft => { draft.splice(i) })
}
}}
>
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
<span className="name">{file.name}</span>
</mdui-chip>
))}
</div> </div>
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon> </motion.div>
</div>
)} )}
</AnimatedHeight> </AnimatePresence>
<AnimatePresence onExitComplete={onCloseReply}>
{replyToVisible && replyTo && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
style={{ overflow: "hidden" }}
>
<div className="reply-preview contextual-preview">
<mdui-icon name="reply" />
<Quote className="reply-content contextual-content" background="surfaceContainer">
<span className="reply-username">{replyTo!.username}</span>
<span className="reply-text">{replyTo!.content}</span>
</Quote>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply}></mdui-button-icon>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence onExitComplete={() => setSelectedFiles([])}>
{attachmentsVisible && selectedFiles.length > 0 && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
style={{ overflow: "hidden" }}
>
<div className="attachments-preview contextual-preview">
<mdui-icon name="attach_file" />
<div className="attachments-chips">
{selectedFiles.map((file, i) => (
<mdui-chip
key={i}
variant="input"
end-icon="close"
title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`}
onClick={() => {
if (selectedFiles.length == 1) {
setAttachmentsVisible(false);
} else {
setSelectedFiles(draft => { draft.splice(i) })
}
}}
>
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
<span className="name">{file.name}</span>
</mdui-chip>
))}
</div>
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="chat-input"> <div className="chat-input">
<div className="left-buttons"> <div className="left-buttons">
<mdui-button-icon <mdui-button-icon
@@ -276,7 +276,7 @@ export function MessageContextMenu({
className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`} className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
style={isEmojiMenuExpanded && !expandUpward ? { style={isEmojiMenuExpanded && !expandUpward ? {
position: 'fixed', position: 'fixed',
top: `${(-(contextMenuHeight || 0) + 95)}px`, top: `${(-(contextMenuHeight || 0) + 50)}px`,
width: '320px', width: '320px',
height: '400px', height: '400px',
zIndex: 1001 zIndex: 1001
@@ -304,18 +304,18 @@ export function MessageContextMenu({
<span className="material-symbols">add</span> <span className="material-symbols">add</span>
</button> </button>
</div> </div>
) : ( ) : (
<div <div
ref={emojiMenuRef} ref={emojiMenuRef}
className="emoji-menu-wrapper"> className="emoji-menu-wrapper">
<EmojiMenu <EmojiMenu
isOpen={true} isOpen={true}
onClose={handleClose} onClose={handleClose}
onEmojiSelect={handleEmojiSelect} onEmojiSelect={handleEmojiSelect}
mode="integrated" mode="integrated"
/> />
</div> </div>
)} )}
</div> </div>
{/* Context Menu */} {/* Context Menu */}
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
import { motion, AnimatePresence } from "motion/react";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel"; import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
import { ChatMessages } from "./ChatMessages"; import { ChatMessages } from "./ChatMessages";
@@ -7,7 +8,6 @@ import { ProfileDialog } from "@/pages/chat/ui/ProfileDialog";
import { setGlobalMessageHandler } from "@/core/websocket"; import { setGlobalMessageHandler } from "@/core/websocket";
import type { Message, WebSocketMessage } from "@/core/types"; import type { Message, WebSocketMessage } from "@/core/types";
import defaultAvatar from "@/images/default-avatar.png"; import defaultAvatar from "@/images/default-avatar.png";
import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity";
import { DMPanel } from "./panels/DMPanel"; import { DMPanel } from "./panels/DMPanel";
import useCall from "@/pages/chat/hooks/useCall"; import useCall from "@/pages/chat/hooks/useCall";
import { TypingIndicator } from "./TypingIndicator"; import { TypingIndicator } from "./TypingIndicator";
@@ -48,8 +48,6 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const { applyPendingPanel, chat, setProfileDialog } = useAppState(); const { applyPendingPanel, chat, setProfileDialog } = useAppState();
const messagePanelRef = useRef<HTMLDivElement>(null); const messagePanelRef = useRef<HTMLDivElement>(null);
const [panelState, setPanelState] = useState<MessagePanelState | null>(null); const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const [switchIn, setSwitchIn] = useState(false);
const [switchOut, setSwitchOut] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const previousMessageCountRef = useRef(0); const previousMessageCountRef = useRef(0);
const [replyTo, setReplyTo] = useState<Message | null>(null); const [replyTo, setReplyTo] = useState<Message | null>(null);
@@ -118,51 +116,32 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
}; };
}, [panel]); }, [panel]);
// Handle chat switching animation with event listeners // Handle chat switching animation
useEffect(() => { useEffect(() => {
if (chat.isSwitching) { if (chat.isSwitching && chat.pendingPanel) {
setSwitchOut(true); // Apply pending panel when animation starts
applyPendingPanel();
// Use animation event listeners instead of hardcoded delays // End switching state after a brief delay to allow animation
function handleAnimationEnd(event: Event) { setTimeout(() => {
const animationEvent = event as AnimationEvent; chat.setIsSwitching(false);
}, 200);
if (animationEvent.animationName === 'fadeOutUp') {
// Apply pending panel exactly at the boundary between animations
applyPendingPanel();
setSwitchOut(false);
setSwitchIn(true);
} else if (animationEvent.animationName === 'fadeInDown') {
setSwitchIn(false);
// End the chat switching state
chat.setIsSwitching(false);
}
};
// Add event listener to document to catch all animation events
document.addEventListener('animationend', handleAnimationEnd);
// Cleanup function
return () => {
document.removeEventListener('animationend', handleAnimationEnd);
};
} }
}, [chat.isSwitching]); }, [chat.isSwitching, chat.pendingPanel, applyPendingPanel]);
// Load messages when panel changes and animation is not running // Load messages when panel changes and animation is not running
useEffect(() => { useEffect(() => {
if (!chat.activePanel || chat.isSwitching || switchOut || switchIn) return; if (!chat.activePanel || chat.isSwitching) return;
const panelState = chat.activePanel.getState(); const panelState = chat.activePanel.getState();
if (panelState.messages.length === 0 && !panelState.isLoading) { if (panelState.messages.length === 0 && !panelState.isLoading) {
chat.activePanel.loadMessages(); chat.activePanel.loadMessages();
} }
}, [chat.activePanel, chat.isSwitching, switchOut, switchIn]); }, [chat.activePanel, chat.isSwitching]);
// Scroll to bottom only when new messages are added // Scroll to bottom only when new messages are added
useEffect(() => { useEffect(() => {
if (!panelState || chat.isSwitching || switchOut || switchIn) return; if (!panelState || chat.isSwitching) return;
const currentMessageCount = panelState.messages.length; const currentMessageCount = panelState.messages.length;
const previousMessageCount = previousMessageCountRef.current; const previousMessageCount = previousMessageCountRef.current;
@@ -186,7 +165,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
// Update the previous message count // Update the previous message count
previousMessageCountRef.current = currentMessageCount; previousMessageCountRef.current = currentMessageCount;
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]); }, [panelState?.messages, panelState?.isLoading, chat.isSwitching]);
function handleCallClick() { function handleCallClick() {
if (panel && panelState && panel.isDm()) { if (panel && panelState && panel.isDm()) {
@@ -213,12 +192,20 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
} }
} }
const panelKey = chat.activePanel?.getState().title || "empty";
return ( return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}> <div className="chat-container">
<div <AnimatePresence mode="wait">
ref={messagePanelRef} <motion.div
className="chat-main" key={panelKey}
id="chat-inner" ref={messagePanelRef}
className="chat-main"
id="chat-inner"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
onDragEnter={panel ? (e) => { onDragEnter={panel ? (e) => {
if (!e.dataTransfer) return; if (!e.dataTransfer) return;
e.preventDefault(); e.preventDefault();
@@ -324,18 +311,26 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
{panel && ( {panel && (
<> <>
<AnimatedOpacity <AnimatePresence>
visible={isDragging} {isDragging && (
className="file-overlay" <motion.div
onDragOver={(e) => e.preventDefault()} initial={{ opacity: 0 }}
onDrop={(e) => e.preventDefault()}> animate={{ opacity: 1 }}
<div className="file-overlay-wrapper"> exit={{ opacity: 0 }}
<div className="file-overlay-inner"> transition={{ duration: 0.5 }}
<mdui-icon name="upload_file" /> className="file-overlay"
<span>Отпустите файл(ы) для добавления</span> onDragOver={(e) => e.preventDefault()}
</div> onDrop={(e) => e.preventDefault()}
</div> >
</AnimatedOpacity> <div className="file-overlay-wrapper">
<div className="file-overlay-inner">
<mdui-icon name="upload_file" />
<span>Отпустите файл(ы) для добавления</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<ChatInputWrapper <ChatInputWrapper
@@ -396,7 +391,8 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
/> />
</> </>
)} )}
</div> </motion.div>
</AnimatePresence>
{/* Profile Dialog */} {/* Profile Dialog */}
<ProfileDialog /> <ProfileDialog />