mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Fix reactions bar expansion and positioning
This commit is contained in:
@@ -968,4 +968,17 @@
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
// Integrated mode styles (inside reaction bar)
|
||||
&.integrated {
|
||||
position: relative !important;
|
||||
width: 320px !important;
|
||||
height: 400px !important;
|
||||
transform: none !important;
|
||||
opacity: 1 !important;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: $color-dark-surface-container;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji menu wrapper inside reaction bar
|
||||
.emoji-menu-wrapper {
|
||||
width: 320px;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// Context menu wrapper with animations
|
||||
.context-menu-wrapper {
|
||||
position: relative;
|
||||
@@ -140,9 +149,9 @@
|
||||
border-radius: 16px;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
min-width: 200px;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
transition: width 0.3s ease-out, height 0.3s ease-out;
|
||||
|
||||
&.left {
|
||||
left: 0;
|
||||
@@ -153,12 +162,55 @@
|
||||
right: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 320px;
|
||||
height: 400px;
|
||||
border-radius: 16px;
|
||||
|
||||
// Default: expand downward from the reaction bar's bottom edge
|
||||
position: absolute;
|
||||
bottom: auto;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: translateY(0);
|
||||
|
||||
&.expand-upward {
|
||||
// Expand upward from the reaction bar's top edge
|
||||
bottom: 100%;
|
||||
top: auto;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 0;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.emoji-menu-wrapper {
|
||||
animation: emojiMenuEnter 0.5s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiMenuEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-bar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
&.faded {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.reaction-emoji-button {
|
||||
|
||||
@@ -83,6 +83,11 @@ button, input {
|
||||
animation: context-menu-open 0.25s ease;
|
||||
}
|
||||
|
||||
&.faded {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes context-menu-open {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
@@ -206,6 +206,7 @@ export function ChatInputWrapper(
|
||||
onClose={() => setEmojiMenuOpen(false)}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
position={emojiMenuPosition}
|
||||
mode="standalone"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Message as MessageType } from "../../../core/types";
|
||||
import type { UserProfile } from "../../../core/types";
|
||||
import { UserProfileDialog } from "./UserProfileDialog";
|
||||
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
import { fetchUserProfile } from "../../../api/profileApi";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { delay } from "../../../utils/utils";
|
||||
@@ -42,16 +41,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null);
|
||||
|
||||
// Emoji menu state (for expanded emoji picker)
|
||||
const [emojiMenu, setEmojiMenu] = useState<{
|
||||
isOpen: boolean;
|
||||
message: MessageType | null;
|
||||
position: { x: number; y: number };
|
||||
}>({
|
||||
isOpen: false,
|
||||
message: null,
|
||||
position: { x: 0, y: 0 }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!deleteDialogOpen) {
|
||||
@@ -157,27 +146,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
}
|
||||
|
||||
|
||||
function handleEmojiMenuClose() {
|
||||
setEmojiMenu(prev => ({ ...prev, isOpen: false }));
|
||||
}
|
||||
|
||||
function handleExpandEmojiMenu(message: MessageType) {
|
||||
const messageElement = document.querySelector(`[data-id="${message.id}"]`);
|
||||
if (messageElement) {
|
||||
const rect = messageElement.getBoundingClientRect();
|
||||
setEmojiMenu({
|
||||
isOpen: true,
|
||||
message,
|
||||
position: { x: rect.left + rect.width / 2, y: rect.bottom + 10 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleEmojiSelect(emoji: string) {
|
||||
if (emojiMenu.message) {
|
||||
handleReactionClick(emojiMenu.message.id, emoji);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -227,7 +195,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
onDelete={handleDelete}
|
||||
onRetry={handleRetry}
|
||||
onReactionClick={handleReactionClick}
|
||||
onExpandEmojiMenu={handleExpandEmojiMenu}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
onOpenChange={handleContextMenuOpenChange}
|
||||
@@ -235,13 +202,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
||||
)}
|
||||
|
||||
|
||||
{/* Emoji Menu */}
|
||||
<EmojiMenu
|
||||
isOpen={emojiMenu.isOpen}
|
||||
onClose={handleEmojiMenuClose}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
position={emojiMenu.position}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,26 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
|
||||
import type { Size2D } from "../../../core/types";
|
||||
|
||||
interface EmojiMenuProps {
|
||||
interface BaseEmojiMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
position: Size2D;
|
||||
}
|
||||
|
||||
export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMenuProps) {
|
||||
interface StandaloneEmojiMenuProps extends BaseEmojiMenuProps {
|
||||
position: Size2D;
|
||||
mode: "standalone";
|
||||
}
|
||||
|
||||
interface IntegratedEmojiMenuProps extends BaseEmojiMenuProps {
|
||||
mode: "integrated";
|
||||
}
|
||||
|
||||
type EmojiMenuProps = StandaloneEmojiMenuProps | IntegratedEmojiMenuProps;
|
||||
|
||||
export function EmojiMenu(props: EmojiMenuProps) {
|
||||
const { isOpen, onClose, onEmojiSelect, mode } = props;
|
||||
const position = mode === "standalone" ? props.position : undefined;
|
||||
const [activeCategory, setActiveCategory] = useState("recent");
|
||||
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -106,13 +118,15 @@ export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMen
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`emoji-menu ${isOpen ? "open" : ""}`}
|
||||
style={{
|
||||
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
|
||||
style={mode === "standalone" && position ? {
|
||||
position: "fixed",
|
||||
left: position.x,
|
||||
bottom: position.y,
|
||||
zIndex: 1000,
|
||||
pointerEvents: isOpen ? "auto" : "none"
|
||||
} : {
|
||||
pointerEvents: isOpen ? "auto" : "none"
|
||||
}}
|
||||
>
|
||||
<div className="emoji-menu-header">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Message, Size2D } from "../../../core/types";
|
||||
import { EmojiMenu } from "./EmojiMenu";
|
||||
|
||||
interface MessageContextMenuProps {
|
||||
message: Message;
|
||||
@@ -9,7 +10,6 @@ interface MessageContextMenuProps {
|
||||
onDelete: (message: Message) => void;
|
||||
onRetry?: (message: Message) => void;
|
||||
onReactionClick?: (messageId: number, emoji: string) => Promise<void>;
|
||||
onExpandEmojiMenu?: (message: Message) => void;
|
||||
position: Size2D;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
@@ -29,7 +29,6 @@ export function MessageContextMenu({
|
||||
onDelete,
|
||||
onRetry,
|
||||
onReactionClick,
|
||||
onExpandEmojiMenu,
|
||||
position,
|
||||
isOpen,
|
||||
onOpenChange
|
||||
@@ -39,11 +38,16 @@ export function MessageContextMenu({
|
||||
const [calculatedPosition, setCalculatedPosition] = useState(position);
|
||||
const [animationClass, setAnimationClass] = useState('entering');
|
||||
const [reactionBarPosition, setReactionBarPosition] = useState<'left' | 'right'>('left');
|
||||
const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false);
|
||||
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const [expandUpward, setExpandUpward] = useState(false);
|
||||
const [contextMenuHeight, setContextMenuHeight] = useState<number | null>(null);
|
||||
|
||||
// Refs for measuring actual dimensions
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const reactionBarRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const emojiMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Calculate smart positioning when component opens
|
||||
useEffect(() => {
|
||||
@@ -148,6 +152,11 @@ export function MessageContextMenu({
|
||||
onOpenChange(false);
|
||||
setIsClosing(false);
|
||||
setAnimationClass('entering'); // Reset for next opening
|
||||
// Reset emoji menu state after context menu animation completes
|
||||
setIsEmojiMenuExpanded(false);
|
||||
setInitialDimensions(null);
|
||||
setExpandUpward(false);
|
||||
setContextMenuHeight(null);
|
||||
}, 200); // Match the animation duration from _animations.scss
|
||||
}
|
||||
|
||||
@@ -215,10 +224,43 @@ export function MessageContextMenu({
|
||||
}
|
||||
|
||||
function handleExpandClick() {
|
||||
if (onExpandEmojiMenu) {
|
||||
onExpandEmojiMenu(message);
|
||||
if (!reactionBarRef.current || !wrapperRef.current) return;
|
||||
|
||||
// Measure the actual dimensions of the reaction bar content
|
||||
const reactionBarRect = reactionBarRef.current.getBoundingClientRect();
|
||||
const wrapperRect = wrapperRef.current.getBoundingClientRect();
|
||||
|
||||
setInitialDimensions({ width: reactionBarRect.width, height: reactionBarRect.height });
|
||||
setContextMenuHeight(wrapperRect.height);
|
||||
|
||||
// Check if expanding downward would cause overflow
|
||||
// Calculate space from the reaction bar's bottom edge downward
|
||||
const viewportHeight = window.innerHeight;
|
||||
const spaceBelow = viewportHeight - reactionBarRect.bottom;
|
||||
const emojiMenuHeight = 400;
|
||||
|
||||
// Only expand upward if there's not enough space below for the emoji menu
|
||||
const shouldExpandUpward = spaceBelow < emojiMenuHeight;
|
||||
setExpandUpward(shouldExpandUpward);
|
||||
|
||||
// Use requestAnimationFrame to ensure the dimensions are applied before expansion
|
||||
requestAnimationFrame(() => {
|
||||
setIsEmojiMenuExpanded(true);
|
||||
});
|
||||
}
|
||||
|
||||
function handleEmojiMenuClose() {
|
||||
setIsEmojiMenuExpanded(false);
|
||||
setInitialDimensions(null);
|
||||
setExpandUpward(false);
|
||||
setContextMenuHeight(null);
|
||||
}
|
||||
|
||||
function handleEmojiSelect(emoji: string) {
|
||||
if (onReactionClick) {
|
||||
onReactionClick(message.id, emoji);
|
||||
}
|
||||
handleClose();
|
||||
handleEmojiMenuClose();
|
||||
}
|
||||
|
||||
return isOpen && (
|
||||
@@ -236,30 +278,55 @@ export function MessageContextMenu({
|
||||
{/* Reaction Bar */}
|
||||
<div
|
||||
ref={reactionBarRef}
|
||||
className={`context-menu-reaction-bar ${reactionBarPosition}`}>
|
||||
{QUICK_REACTIONS.map((emoji, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="reaction-emoji-button"
|
||||
onClick={async () => await handleReactionClick(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="reaction-expand-button"
|
||||
onClick={handleExpandClick}
|
||||
title="More emojis"
|
||||
>
|
||||
<span className="material-symbols">add</span>
|
||||
</button>
|
||||
className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
|
||||
style={isEmojiMenuExpanded && !expandUpward ? {
|
||||
position: 'fixed',
|
||||
top: `${(-(contextMenuHeight || 0) + 5)}px`,
|
||||
width: '320px',
|
||||
height: '400px',
|
||||
zIndex: 1001
|
||||
} : initialDimensions && !isEmojiMenuExpanded ? {
|
||||
width: `${initialDimensions.width}px`,
|
||||
height: `${initialDimensions.height}px`
|
||||
} : {}}>
|
||||
{!isEmojiMenuExpanded ? (
|
||||
<div className="reaction-bar-content">
|
||||
{QUICK_REACTIONS.map((emoji, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="reaction-emoji-button"
|
||||
onClick={async () => await handleReactionClick(emoji)}
|
||||
title={emoji}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="reaction-expand-button"
|
||||
onClick={handleExpandClick}
|
||||
title="More emojis"
|
||||
>
|
||||
<span className="material-symbols">add</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={emojiMenuRef}
|
||||
className="emoji-menu-wrapper">
|
||||
<EmojiMenu
|
||||
isOpen={true}
|
||||
onClose={handleEmojiMenuClose}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
mode="integrated"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="context-menu">
|
||||
className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}>
|
||||
{actions.map((action, i) => (
|
||||
action.show && (
|
||||
<div
|
||||
@@ -273,6 +340,7 @@ export function MessageContextMenu({
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user