mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-07 07:52:44 +00:00
Compare commits
5 Commits
cli/v0.2.0
...
chat-send-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06444ab76e | ||
|
|
b9b949770c | ||
|
|
cc16432ec3 | ||
|
|
237c5b0ed8 | ||
|
|
7a5e03613e |
@@ -64,6 +64,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"favicon-fetch": "^1.0.0",
|
||||
"formik": "^2.2.9",
|
||||
"framer-motion": "^12.23.26",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"katex": "^0.16.17",
|
||||
|
||||
150
web/src/app/chat/components/AnimatedMessageBubble.tsx
Normal file
150
web/src/app/chat/components/AnimatedMessageBubble.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AnimatedMessageBubbleProps {
|
||||
content: string;
|
||||
startRect: DOMRect;
|
||||
targetRect: DOMRect | null;
|
||||
onAnimationComplete: () => void;
|
||||
targetRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export default function AnimatedMessageBubble({
|
||||
content,
|
||||
startRect,
|
||||
targetRect: initialTargetRect,
|
||||
onAnimationComplete,
|
||||
targetRef,
|
||||
}: AnimatedMessageBubbleProps) {
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
const [liveTargetRect, setLiveTargetRect] = useState<DOMRect | null>(
|
||||
initialTargetRect
|
||||
);
|
||||
const [animationStarted, setAnimationStarted] = useState(false);
|
||||
const [animationDurationS, setAnimationDurationS] = useState(0.3);
|
||||
const [neutralBg, setNeutralBg] = useState<string>(
|
||||
"var(--background-neutral-00)"
|
||||
);
|
||||
const [tintBg, setTintBg] = useState<string>("var(--background-tint-02)");
|
||||
const prevTargetRectRef = useRef<DOMRect | null>(null);
|
||||
|
||||
const handleAnimationComplete = React.useCallback(() => {
|
||||
if (targetRef?.current && bubbleRef.current) {
|
||||
const finalRect = targetRef.current.getBoundingClientRect();
|
||||
const finalDeltaX = finalRect.left - startRect.left;
|
||||
const finalDeltaY = finalRect.top - startRect.top;
|
||||
bubbleRef.current.style.transform = `translate(${finalDeltaX}px, ${finalDeltaY}px)`;
|
||||
bubbleRef.current.style.width = `${finalRect.width}px`;
|
||||
bubbleRef.current.style.height = `${finalRect.height}px`;
|
||||
}
|
||||
onAnimationComplete();
|
||||
}, [onAnimationComplete, startRect.left, startRect.top, targetRef]);
|
||||
|
||||
// Read the CSS variable so styling controls duration.
|
||||
useLayoutEffect(() => {
|
||||
if (!bubbleRef.current) return;
|
||||
const raw = getComputedStyle(bubbleRef.current).getPropertyValue(
|
||||
"--message-send-duration"
|
||||
);
|
||||
const parsed = raw.trim().toLowerCase();
|
||||
let seconds = parseFloat(parsed);
|
||||
if (parsed.endsWith("ms")) seconds = seconds / 1000;
|
||||
if (Number.isNaN(seconds) || seconds <= 0) return;
|
||||
setAnimationDurationS(seconds);
|
||||
}, []);
|
||||
|
||||
// Resolve CSS color variables to concrete values for Framer Motion to tween.
|
||||
useLayoutEffect(() => {
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const neutral = rootStyles
|
||||
.getPropertyValue("--background-neutral-00")
|
||||
.trim();
|
||||
const tint = rootStyles.getPropertyValue("--background-tint-02").trim();
|
||||
if (neutral) setNeutralBg(neutral);
|
||||
if (tint) setTintBg(tint);
|
||||
}, []);
|
||||
|
||||
// Continuously track the latest target rect so the animation follows scroll/resize
|
||||
useLayoutEffect(() => {
|
||||
if (!targetRef?.current) return;
|
||||
|
||||
let frameId: number;
|
||||
const updateRect = () => {
|
||||
if (!targetRef?.current) return;
|
||||
const nextRect = targetRef.current.getBoundingClientRect();
|
||||
const prevRect = prevTargetRectRef.current;
|
||||
const changed =
|
||||
!prevRect ||
|
||||
Math.abs(prevRect.left - nextRect.left) > 0.01 ||
|
||||
Math.abs(prevRect.top - nextRect.top) > 0.01 ||
|
||||
Math.abs(prevRect.width - nextRect.width) > 0.01 ||
|
||||
Math.abs(prevRect.height - nextRect.height) > 0.01;
|
||||
|
||||
if (changed) {
|
||||
prevTargetRectRef.current = nextRect;
|
||||
setLiveTargetRect(nextRect);
|
||||
}
|
||||
|
||||
frameId = requestAnimationFrame(updateRect);
|
||||
};
|
||||
|
||||
// Set initial rect immediately
|
||||
const initialRect = targetRef.current.getBoundingClientRect();
|
||||
prevTargetRectRef.current = initialRect;
|
||||
setLiveTargetRect(initialRect);
|
||||
frameId = requestAnimationFrame(updateRect);
|
||||
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [targetRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((liveTargetRect || initialTargetRect) && !animationStarted) {
|
||||
setAnimationStarted(true);
|
||||
}
|
||||
}, [animationStarted, initialTargetRect, liveTargetRect]);
|
||||
|
||||
const targetRect = liveTargetRect ?? initialTargetRect;
|
||||
const targetWidth = targetRect?.width ?? startRect.width;
|
||||
const targetHeight = targetRect?.height ?? startRect.height;
|
||||
const targetLeft = targetRect?.left ?? startRect.left;
|
||||
const targetTop = targetRect?.top ?? startRect.top;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={bubbleRef}
|
||||
className={cn(
|
||||
"fixed pointer-events-none z-[9999] whitespace-break-spaces rounded-t-16 rounded-bl-16 py-2 px-3 text-user-text overflow-hidden animated-message-bubble box-border"
|
||||
)}
|
||||
style={{
|
||||
willChange: "transform, width, height, background-color",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
initial={{
|
||||
left: startRect.left,
|
||||
top: startRect.top,
|
||||
width: startRect.width,
|
||||
height: startRect.height,
|
||||
backgroundColor: neutralBg,
|
||||
}}
|
||||
animate={{
|
||||
left: targetLeft,
|
||||
top: targetTop,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
backgroundColor: tintBg,
|
||||
}}
|
||||
transition={{
|
||||
duration: animationDurationS,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
backgroundColor: { duration: animationDurationS },
|
||||
}}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
>
|
||||
<Text mainContentBody>{content}</Text>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -81,6 +81,11 @@ import AppPageLayout from "@/layouts/AppPageLayout";
|
||||
import { HeaderData } from "@/lib/headers/fetchHeaderDataSS";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import SvgChevronDown from "@/icons/chevron-down";
|
||||
import {
|
||||
MessageAnimationProvider,
|
||||
useMessageAnimation,
|
||||
} from "@/refresh-components/contexts/MessageAnimationContext";
|
||||
import AnimatedMessageBubble from "@/app/chat/components/AnimatedMessageBubble";
|
||||
|
||||
const DEFAULT_CONTEXT_TOKENS = 120_000;
|
||||
|
||||
@@ -90,11 +95,25 @@ interface ChatPageProps {
|
||||
headerData: HeaderData;
|
||||
}
|
||||
|
||||
export default function ChatPage({
|
||||
function ChatPageInner({
|
||||
documentSidebarInitialWidth,
|
||||
firstMessage,
|
||||
headerData,
|
||||
}: ChatPageProps) {
|
||||
const { animatingMessage, clearAnimation } = useMessageAnimation();
|
||||
const [targetMessageRect, setTargetMessageRect] = useState<DOMRect | null>(
|
||||
null
|
||||
);
|
||||
const latestUserMessageRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Track the position of the latest user message for animation
|
||||
useEffect(() => {
|
||||
if (animatingMessage && latestUserMessageRef.current) {
|
||||
const rect = latestUserMessageRef.current.getBoundingClientRect();
|
||||
setTargetMessageRect(rect);
|
||||
}
|
||||
}, [animatingMessage]);
|
||||
|
||||
// Performance tracking
|
||||
// Keeping this here in case we need to track down slow renders in the future
|
||||
// const renderCount = useRef(0);
|
||||
@@ -768,6 +787,17 @@ export default function ChatPage({
|
||||
<>
|
||||
<HealthCheckBanner />
|
||||
|
||||
{/* Animated flying bubble during message send */}
|
||||
{animatingMessage && targetMessageRect && (
|
||||
<AnimatedMessageBubble
|
||||
content={animatingMessage.content}
|
||||
startRect={animatingMessage.startRect}
|
||||
targetRect={targetMessageRect}
|
||||
onAnimationComplete={clearAnimation}
|
||||
targetRef={latestUserMessageRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ChatPopup is a custom popup that displays a admin-specified message on initial user visit.
|
||||
Only used in the EE version of the app. */}
|
||||
{popup}
|
||||
@@ -873,6 +903,7 @@ export default function ChatPage({
|
||||
hasPerformedInitialScroll={hasPerformedInitialScroll}
|
||||
chatSessionId={chatSessionId}
|
||||
enterpriseSettings={enterpriseSettings}
|
||||
latestUserMessageRef={latestUserMessageRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -970,6 +1001,7 @@ export default function ChatPage({
|
||||
onboardingState.currentStep !==
|
||||
OnboardingStep.Complete)
|
||||
}
|
||||
hasMessages={messageHistory.length > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -999,3 +1031,11 @@ export default function ChatPage({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatPage(props: ChatPageProps) {
|
||||
return (
|
||||
<MessageAnimationProvider>
|
||||
<ChatPageInner {...props} />
|
||||
</MessageAnimationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ interface MessagesDisplayProps {
|
||||
hasPerformedInitialScroll: boolean;
|
||||
chatSessionId: string | null;
|
||||
enterpriseSettings?: EnterpriseSettings | null;
|
||||
latestUserMessageRef?: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const MessagesDisplay: React.FC<MessagesDisplayProps> = ({
|
||||
@@ -72,6 +73,7 @@ export const MessagesDisplay: React.FC<MessagesDisplayProps> = ({
|
||||
hasPerformedInitialScroll,
|
||||
chatSessionId,
|
||||
enterpriseSettings,
|
||||
latestUserMessageRef,
|
||||
}) => {
|
||||
// Stable fallbacks to avoid changing prop identities on each render
|
||||
const emptyDocs = useMemo<OnyxDocument[]>(() => [], []);
|
||||
@@ -114,6 +116,17 @@ export const MessagesDisplay: React.FC<MessagesDisplayProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the last user message index for animation
|
||||
const lastUserMessageIndex = useMemo(() => {
|
||||
for (let i = messageHistory.length - 1; i >= 0; i--) {
|
||||
const message = messageHistory[i];
|
||||
if (message && message.type === "user") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}, [messageHistory]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ overflowAnchor: "none" }}
|
||||
@@ -135,6 +148,7 @@ export const MessagesDisplay: React.FC<MessagesDisplayProps> = ({
|
||||
if (message.type === "user") {
|
||||
const nextMessage =
|
||||
messageHistory.length > i + 1 ? messageHistory[i + 1] : null;
|
||||
const isLatestUserMessage = i === lastUserMessageIndex;
|
||||
|
||||
return (
|
||||
<div id={messageReactComponentKey} key={messageReactComponentKey}>
|
||||
@@ -151,6 +165,9 @@ export const MessagesDisplay: React.FC<MessagesDisplayProps> = ({
|
||||
parentMessage?.childrenNodeIds ?? emptyChildrenIds
|
||||
}
|
||||
onMessageSelection={onMessageSelection}
|
||||
bubbleRef={
|
||||
isLatestUserMessage ? latestUserMessageRef : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { truncateString, cn, hasNonImageFiles } from "@/lib/utils";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
|
||||
import { FileCard } from "./FileCard";
|
||||
import { FileCard } from "@/app/chat/components/input/FileCard";
|
||||
import {
|
||||
ProjectFile,
|
||||
UserFileStatus,
|
||||
@@ -38,7 +38,8 @@ import SvgPlusCircle from "@/icons/plus-circle";
|
||||
import {
|
||||
getIconForAction,
|
||||
hasSearchToolsAvailable,
|
||||
} from "../../services/actionUtils";
|
||||
} from "@/app/chat/services/actionUtils";
|
||||
import { useMessageAnimation } from "@/refresh-components/contexts/MessageAnimationContext";
|
||||
|
||||
const MAX_INPUT_HEIGHT = 200;
|
||||
|
||||
@@ -105,6 +106,7 @@ export interface ChatInputBarProps {
|
||||
setPresentingDocument?: (document: MinimalOnyxDocument) => void;
|
||||
toggleDeepResearch: () => void;
|
||||
disabled: boolean;
|
||||
hasMessages: boolean;
|
||||
}
|
||||
|
||||
function ChatInputBarInner({
|
||||
@@ -130,10 +132,13 @@ function ChatInputBarInner({
|
||||
toggleDeepResearch,
|
||||
setPresentingDocument,
|
||||
disabled,
|
||||
hasMessages,
|
||||
}: ChatInputBarProps) {
|
||||
const { user } = useUser();
|
||||
const { forcedToolIds, setForcedToolIds } = useForcedTools();
|
||||
const { currentMessageFiles, setCurrentMessageFiles } = useProjectsContext();
|
||||
const { startMessageAnimation } = useMessageAnimation();
|
||||
const inputContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentIndexingFiles = useMemo(() => {
|
||||
return currentMessageFiles.filter(
|
||||
@@ -417,7 +422,10 @@ function ChatInputBarInner({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full h-full flex flex-col shadow-01 bg-background-neutral-00 rounded-16">
|
||||
<div
|
||||
ref={inputContainerRef}
|
||||
className="w-full h-full flex flex-col shadow-01 bg-background-neutral-00 rounded-16"
|
||||
>
|
||||
{currentMessageFiles.length > 0 && (
|
||||
<div className="p-1 rounded-t-16 flex flex-wrap gap-2">
|
||||
{currentMessageFiles.map((file) => (
|
||||
@@ -469,6 +477,16 @@ function ChatInputBarInner({
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (message) {
|
||||
// Capture the textarea position before submit
|
||||
if (hasMessages && chatState === "input") {
|
||||
const sourceEl =
|
||||
inputContainerRef.current ?? textAreaRef.current;
|
||||
if (sourceEl) {
|
||||
const rect = sourceEl.getBoundingClientRect();
|
||||
startMessageAnimation(message, rect);
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
@@ -645,6 +663,15 @@ function ChatInputBarInner({
|
||||
if (chatState == "streaming") {
|
||||
stopGenerating();
|
||||
} else if (message) {
|
||||
// Capture the textarea position before submit
|
||||
if (hasMessages && chatState === "input") {
|
||||
const sourceEl =
|
||||
inputContainerRef.current ?? textAreaRef.current;
|
||||
if (sourceEl) {
|
||||
const rect = sourceEl.getBoundingClientRect();
|
||||
startMessageAnimation(message, rect);
|
||||
}
|
||||
}
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -14,6 +14,7 @@ import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import SvgEdit from "@/icons/edit";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import ExpandableContentWrapper from "@/components/tools/ExpandableContentWrapper";
|
||||
import { useMessageAnimation } from "@/refresh-components/contexts/MessageAnimationContext";
|
||||
|
||||
interface FileDisplayProps {
|
||||
files: FileDescriptor[];
|
||||
@@ -177,6 +178,9 @@ interface HumanMessageProps {
|
||||
// Streaming and generation
|
||||
stopGenerating?: () => void;
|
||||
disableSwitchingForStreaming?: boolean;
|
||||
|
||||
// Animation ref
|
||||
bubbleRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export default function HumanMessage({
|
||||
@@ -189,6 +193,7 @@ export default function HumanMessage({
|
||||
shared,
|
||||
stopGenerating = () => null,
|
||||
disableSwitchingForStreaming = false,
|
||||
bubbleRef,
|
||||
}: HumanMessageProps) {
|
||||
// TODO (@raunakab):
|
||||
//
|
||||
@@ -199,6 +204,10 @@ export default function HumanMessage({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Check if this message is currently animating
|
||||
const { animatingMessage } = useMessageAnimation();
|
||||
const isAnimating = animatingMessage?.content === content && !!bubbleRef;
|
||||
|
||||
const currentMessageInd = messageId
|
||||
? otherMessagesCanSwitchTo?.indexOf(messageId)
|
||||
: undefined;
|
||||
@@ -228,7 +237,7 @@ export default function HumanMessage({
|
||||
return (
|
||||
<div
|
||||
id="onyx-human-message"
|
||||
className="pt-5 pb-1 w-full lg:px-5 flex -mr-6 relative"
|
||||
className="pt-5 pb-1 w-full lg:px-5 flex -mr-6 relative dbg-red"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
@@ -243,7 +252,7 @@ export default function HumanMessage({
|
||||
<FileDisplay alignBubble files={files || []} />
|
||||
|
||||
<div className="flex justify-end mt-1">
|
||||
<div className="w-full ml-8 flex w-full w-[800px] break-words">
|
||||
<div className="w-full ml-8 flex dbg-red break-words">
|
||||
{isEditing ? (
|
||||
<MessageEditing
|
||||
content={content}
|
||||
@@ -284,14 +293,16 @@ export default function HumanMessage({
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
className={cn(
|
||||
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3",
|
||||
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 dbg-red bg-background-tint-02 py-2 px-3",
|
||||
!(
|
||||
onEdit &&
|
||||
isHovered &&
|
||||
!isEditing &&
|
||||
(!files || files.length === 0)
|
||||
) && "ml-auto"
|
||||
) && "ml-auto",
|
||||
isAnimating && "opacity-0"
|
||||
)}
|
||||
>
|
||||
<Text mainContentBody>{content}</Text>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { RefObject, useCallback } from "react";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import { FileDescriptor } from "@/app/chat/interfaces";
|
||||
import HumanMessage from "./HumanMessage";
|
||||
@@ -12,6 +12,7 @@ interface BaseMemoizedHumanMessageProps {
|
||||
shared?: boolean;
|
||||
stopGenerating?: () => void;
|
||||
disableSwitchingForStreaming?: boolean;
|
||||
bubbleRef?: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
interface InternalMemoizedHumanMessageProps
|
||||
@@ -33,6 +34,7 @@ const _MemoizedHumanMessage = React.memo(function _MemoizedHumanMessage({
|
||||
stopGenerating,
|
||||
disableSwitchingForStreaming,
|
||||
onEdit,
|
||||
bubbleRef,
|
||||
}: InternalMemoizedHumanMessageProps) {
|
||||
return (
|
||||
<HumanMessage
|
||||
@@ -45,6 +47,7 @@ const _MemoizedHumanMessage = React.memo(function _MemoizedHumanMessage({
|
||||
stopGenerating={stopGenerating}
|
||||
disableSwitchingForStreaming={disableSwitchingForStreaming}
|
||||
onEdit={onEdit}
|
||||
bubbleRef={bubbleRef}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -59,6 +62,7 @@ export const MemoizedHumanMessage = ({
|
||||
stopGenerating,
|
||||
disableSwitchingForStreaming,
|
||||
handleEditWithMessageId,
|
||||
bubbleRef,
|
||||
}: MemoizedHumanMessageProps) => {
|
||||
const onEdit = useCallback(
|
||||
(editedContent: string) => {
|
||||
@@ -85,6 +89,7 @@ export const MemoizedHumanMessage = ({
|
||||
stopGenerating={stopGenerating}
|
||||
disableSwitchingForStreaming={disableSwitchingForStreaming}
|
||||
onEdit={onEdit}
|
||||
bubbleRef={bubbleRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
8
web/src/app/css/animations.css
Normal file
8
web/src/app/css/animations.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.animated-message-bubble {
|
||||
--message-send-duration: 0.3s;
|
||||
background-color: var(--background-neutral-00);
|
||||
}
|
||||
|
||||
.animated-message-bubble--target {
|
||||
background-color: var(--background-tint-02);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
@import "css/colors.css";
|
||||
@import "css/inputs.css";
|
||||
@import "css/line-item.css";
|
||||
@import "css/animations.css";
|
||||
@import "css/switch.css";
|
||||
|
||||
@tailwind base;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback } from "react";
|
||||
|
||||
interface AnimatingMessage {
|
||||
content: string;
|
||||
startRect: DOMRect;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface MessageAnimationContextType {
|
||||
animatingMessage: AnimatingMessage | null;
|
||||
startMessageAnimation: (content: string, startRect: DOMRect) => void;
|
||||
clearAnimation: () => void;
|
||||
}
|
||||
|
||||
const MessageAnimationContext = createContext<
|
||||
MessageAnimationContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function MessageAnimationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [animatingMessage, setAnimatingMessage] =
|
||||
useState<AnimatingMessage | null>(null);
|
||||
|
||||
const startMessageAnimation = useCallback(
|
||||
(content: string, startRect: DOMRect) => {
|
||||
setAnimatingMessage({
|
||||
content,
|
||||
startRect,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const clearAnimation = useCallback(() => {
|
||||
setAnimatingMessage(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MessageAnimationContext.Provider
|
||||
value={{
|
||||
animatingMessage,
|
||||
startMessageAnimation,
|
||||
clearAnimation,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MessageAnimationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMessageAnimation() {
|
||||
const context = useContext(MessageAnimationContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useMessageAnimation must be used within MessageAnimationProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user