mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-06 01:15:28 +02:00
chore: make the Unleash AI chat resizable (#8456)
https://linear.app/unleash/issue/2-2840/make-the-unleash-ai-chat-window-resizable This PR makes the Unleash AI chat resizable, providing users with a flexible way to adjust the chat window's size. Implements a reusable `Resizable` wrapper component that allows configuration of: - Minimum, maximum, and default sizes. - Customizable resize handlers for each edge and corner of the container. - Optional resize event callbacks. Double-clicking any resize handler maximizes the container along that axis (or both, if it's a corner). If the container is already maximized, double-clicking again will revert it to the default size.
This commit is contained in:
parent
6d2b882eb8
commit
9a98f86077
@ -13,6 +13,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|||||||
import { AIChatInput } from './AIChatInput';
|
import { AIChatInput } from './AIChatInput';
|
||||||
import { AIChatMessage } from './AIChatMessage';
|
import { AIChatMessage } from './AIChatMessage';
|
||||||
import { AIChatHeader } from './AIChatHeader';
|
import { AIChatHeader } from './AIChatHeader';
|
||||||
|
import { Resizable } from 'component/common/Resizable/Resizable';
|
||||||
|
|
||||||
const StyledAIIconContainer = styled('div')(({ theme }) => ({
|
const StyledAIIconContainer = styled('div')(({ theme }) => ({
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
@ -37,19 +38,26 @@ const StyledAIChatContainer = styled(StyledAIIconContainer)({
|
|||||||
right: 10,
|
right: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const StyledResizable = styled(Resizable)(({ theme }) => ({
|
||||||
|
boxShadow: theme.boxShadows.popup,
|
||||||
|
borderRadius: theme.shape.borderRadiusLarge,
|
||||||
|
}));
|
||||||
|
|
||||||
const StyledAIIconButton = styled(IconButton)(({ theme }) => ({
|
const StyledAIIconButton = styled(IconButton)(({ theme }) => ({
|
||||||
background: theme.palette.primary.light,
|
background: theme.palette.primary.light,
|
||||||
color: theme.palette.primary.contrastText,
|
color: theme.palette.primary.contrastText,
|
||||||
boxShadow: theme.boxShadows.popup,
|
boxShadow: theme.boxShadows.popup,
|
||||||
|
transition: 'background 0.3s',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
background: theme.palette.primary.dark,
|
background: theme.palette.primary.dark,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledChat = styled('div')(({ theme }) => ({
|
const StyledChat = styled('div')(({ theme }) => ({
|
||||||
borderRadius: theme.shape.borderRadiusLarge,
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: theme.boxShadows.popup,
|
|
||||||
background: theme.palette.background.paper,
|
background: theme.palette.background.paper,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -58,9 +66,9 @@ const StyledChatContent = styled('div')(({ theme }) => ({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
padding: theme.spacing(2),
|
padding: theme.spacing(2),
|
||||||
paddingBottom: theme.spacing(1),
|
paddingBottom: theme.spacing(1),
|
||||||
width: '30vw',
|
flex: 1,
|
||||||
height: '50vh',
|
overflowY: 'auto',
|
||||||
overflow: 'auto',
|
overflowX: 'hidden',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const initialMessages: ChatMessage[] = [
|
const initialMessages: ChatMessage[] = [
|
||||||
@ -134,6 +142,13 @@ export const AIChat = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledAIChatContainer>
|
<StyledAIChatContainer>
|
||||||
|
<StyledResizable
|
||||||
|
handlers={['top-left', 'top', 'left']}
|
||||||
|
minSize={{ width: '270px', height: '200px' }}
|
||||||
|
maxSize={{ width: '90vw', height: '90vh' }}
|
||||||
|
defaultSize={{ width: '320px', height: '450px' }}
|
||||||
|
onResize={scrollToEnd}
|
||||||
|
>
|
||||||
<StyledChat>
|
<StyledChat>
|
||||||
<AIChatHeader
|
<AIChatHeader
|
||||||
onNew={() => setMessages(initialMessages)}
|
onNew={() => setMessages(initialMessages)}
|
||||||
@ -152,6 +167,7 @@ export const AIChat = () => {
|
|||||||
</StyledChatContent>
|
</StyledChatContent>
|
||||||
<AIChatInput onSend={onSend} loading={loading} />
|
<AIChatInput onSend={onSend} loading={loading} />
|
||||||
</StyledChat>
|
</StyledChat>
|
||||||
|
</StyledResizable>
|
||||||
</StyledAIChatContainer>
|
</StyledAIChatContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
296
frontend/src/component/common/Resizable/Resizable.tsx
Normal file
296
frontend/src/component/common/Resizable/Resizable.tsx
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import { styled } from '@mui/material';
|
||||||
|
import { type HTMLAttributes, useRef, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
const StyledResizableWrapper = styled('div', {
|
||||||
|
shouldForwardProp: (prop) => prop !== 'animate',
|
||||||
|
})<{ animate: boolean }>(({ animate }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: animate ? 'width 0.3s, height 0.3s' : 'none',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledResizeHandle = styled('div')({
|
||||||
|
position: 'absolute',
|
||||||
|
background: 'transparent',
|
||||||
|
zIndex: 1,
|
||||||
|
'&.top-left': {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
cursor: 'nwse-resize',
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
'&.top-right': {
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
cursor: 'nesw-resize',
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
'&.bottom-left': {
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
cursor: 'nesw-resize',
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
'&.bottom-right': {
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
cursor: 'nwse-resize',
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
'&.top': {
|
||||||
|
top: 0,
|
||||||
|
left: '50%',
|
||||||
|
cursor: 'ns-resize',
|
||||||
|
width: '100%',
|
||||||
|
height: '5px',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
},
|
||||||
|
'&.right': {
|
||||||
|
top: '50%',
|
||||||
|
right: 0,
|
||||||
|
cursor: 'ew-resize',
|
||||||
|
width: '5px',
|
||||||
|
height: '100%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
},
|
||||||
|
'&.bottom': {
|
||||||
|
bottom: 0,
|
||||||
|
left: '50%',
|
||||||
|
cursor: 'ns-resize',
|
||||||
|
width: '100%',
|
||||||
|
height: '5px',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
},
|
||||||
|
'&.left': {
|
||||||
|
top: '50%',
|
||||||
|
left: 0,
|
||||||
|
cursor: 'ew-resize',
|
||||||
|
width: '5px',
|
||||||
|
height: '100%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type Handler =
|
||||||
|
| 'top'
|
||||||
|
| 'right'
|
||||||
|
| 'bottom'
|
||||||
|
| 'left'
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-right';
|
||||||
|
|
||||||
|
type Size = { width: string; height: string };
|
||||||
|
|
||||||
|
interface IResizableProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
handlers: Handler[];
|
||||||
|
minSize: Size;
|
||||||
|
maxSize: Size;
|
||||||
|
defaultSize?: Size;
|
||||||
|
onResize?: () => void;
|
||||||
|
onResizeEnd?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Resizable = ({
|
||||||
|
handlers,
|
||||||
|
minSize,
|
||||||
|
maxSize,
|
||||||
|
defaultSize = minSize,
|
||||||
|
onResize,
|
||||||
|
onResizeEnd,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: IResizableProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [currentSize, setCurrentSize] = useState(defaultSize);
|
||||||
|
const [animate, setAnimate] = useState(false);
|
||||||
|
|
||||||
|
const handleResize = (
|
||||||
|
e: React.MouseEvent<HTMLDivElement>,
|
||||||
|
direction:
|
||||||
|
| 'top'
|
||||||
|
| 'right'
|
||||||
|
| 'bottom'
|
||||||
|
| 'left'
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-right',
|
||||||
|
) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const chatContainer = containerRef.current;
|
||||||
|
if (!chatContainer) return;
|
||||||
|
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startY = e.clientY;
|
||||||
|
const startWidth = chatContainer.offsetWidth;
|
||||||
|
const startHeight = chatContainer.offsetHeight;
|
||||||
|
|
||||||
|
setAnimate(false);
|
||||||
|
|
||||||
|
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
let newWidth = startWidth;
|
||||||
|
let newHeight = startHeight;
|
||||||
|
|
||||||
|
if (direction.includes('top')) {
|
||||||
|
newHeight = Math.max(
|
||||||
|
Number.parseInt(minSize.height),
|
||||||
|
startHeight - (moveEvent.clientY - startY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction.includes('bottom')) {
|
||||||
|
newHeight = Math.max(
|
||||||
|
Number.parseInt(minSize.height),
|
||||||
|
startHeight + (moveEvent.clientY - startY),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction.includes('left')) {
|
||||||
|
newWidth = Math.max(
|
||||||
|
Number.parseInt(minSize.width),
|
||||||
|
startWidth - (moveEvent.clientX - startX),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction.includes('right')) {
|
||||||
|
newWidth = Math.max(
|
||||||
|
Number.parseInt(minSize.width),
|
||||||
|
startWidth + (moveEvent.clientX - startX),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentSize({
|
||||||
|
width: `${newWidth}px`,
|
||||||
|
height: `${newHeight}px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
onResize?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
|
|
||||||
|
onResizeEnd?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
document.addEventListener('mouseup', onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoubleClick = (direction: Handler) => {
|
||||||
|
const chatContainer = containerRef.current;
|
||||||
|
if (!chatContainer) return;
|
||||||
|
|
||||||
|
const currentWidth = chatContainer.style.width;
|
||||||
|
const currentHeight = chatContainer.style.height;
|
||||||
|
|
||||||
|
setAnimate(true);
|
||||||
|
|
||||||
|
if (direction.includes('top') || direction.includes('bottom')) {
|
||||||
|
if (currentHeight === maxSize.height) {
|
||||||
|
chatContainer.style.height = defaultSize.height;
|
||||||
|
} else {
|
||||||
|
chatContainer.style.height = maxSize.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction.includes('left') || direction.includes('right')) {
|
||||||
|
if (currentWidth === maxSize.width) {
|
||||||
|
chatContainer.style.width = defaultSize.width;
|
||||||
|
} else {
|
||||||
|
chatContainer.style.width = maxSize.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onResizeEnd?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledResizableWrapper
|
||||||
|
ref={containerRef}
|
||||||
|
animate={animate}
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
width: currentSize.width,
|
||||||
|
height: currentSize.height,
|
||||||
|
minWidth: minSize.width,
|
||||||
|
minHeight: minSize.height,
|
||||||
|
maxWidth: maxSize.width,
|
||||||
|
maxHeight: maxSize.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{handlers.includes('top-left') && (
|
||||||
|
<StyledResizeHandle
|
||||||
|
className='top-left'
|
||||||
|
onMouseDown={(e) => handleResize(e, 'top-left')}
|
||||||
|
onDoubleClick={() => handleDoubleClick('top-left')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.includes('top-right') && (
|
||||||
|
<StyledResizeHandle
|
||||||
|
className='top-right'
|
||||||
|
onMouseDown={(e) => handleResize(e, 'top-right')}
|
||||||
|
onDoubleClick={() => handleDoubleClick('top-right')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.includes('bottom-left') && (
|
||||||
|
<StyledResizeHandle
|
||||||
|
className='bottom-left'
|
||||||
|
onMouseDown={(e) => handleResize(e, 'bottom-left')}
|
||||||
|
onDoubleClick={() => handleDoubleClick('bottom-left')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.includes('bottom-right') && (
|
||||||
|
<StyledResizeHandle
|
||||||
|
className='bottom-right'
|
||||||
|
onMouseDown={(e) => handleResize(e, 'bottom-right')}
|
||||||
|
onDoubleClick={() => handleDoubleClick('bottom-right')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.includes('top') && (
|
||||||
|
<StyledResizeHandle
|
||||||
|
className='top'
|
||||||
|
onMouseDown={(e) => handleResize(e, 'top')}
|
||||||
|
onDoubleClick={() => handleDoubleClick('top')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.includes('right') && (
|
||||||
|
<StyledResizeHandle
|
||||||
|
className='right'
|
||||||
|
onMouseDown={(e) => handleResize(e, 'right')}
|
||||||
|
onDoubleClick={() => handleDoubleClick('right')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.includes('bottom') && (
|
||||||
|
<StyledResizeHandle
|
||||||
|
className='bottom'
|
||||||
|
onMouseDown={(e) => handleResize(e, 'bottom')}
|
||||||
|
onDoubleClick={() => handleDoubleClick('bottom')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handlers.includes('left') && (
|
||||||
|
<StyledResizeHandle
|
||||||
|
className='left'
|
||||||
|
onMouseDown={(e) => handleResize(e, 'left')}
|
||||||
|
onDoubleClick={() => handleDoubleClick('left')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</StyledResizableWrapper>
|
||||||
|
);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user