fix chat workspace scrolling and layout

This commit is contained in:
lizerk 2026-08-06 09:52:17 +03:00
parent 02311f5ead
commit 3643bea2fc
5 changed files with 66 additions and 72 deletions

View File

@ -1328,29 +1328,6 @@ function MessageDialogPageContent() {
return () => vv.removeEventListener("resize", onResize);
}, []);
// Redirect wheel events outside chat container into it
useEffect(() => {
const onWheel = (e: WheelEvent) => {
if (e.defaultPrevented || mediaPreview) return;
const c = messagesViewportRef.current;
if (!c?.containerRef?.current) return;
const v = c.containerRef.current;
const t = e.target as HTMLElement | null;
if (
!t ||
v.contains(t) ||
t.closest("[data-overlay-root='true'], [role='dialog'], [aria-modal='true']") ||
t.closest("textarea,input,button,a,video,audio,[contenteditable='true']")
) {
return;
}
e.preventDefault();
v.scrollTop += e.deltaY;
};
window.addEventListener("wheel", onWheel, { passive: false });
return () => window.removeEventListener("wheel", onWheel);
}, [mediaPreview]);
/* ──────────────────────────────────────────────────────
* RENDER
* ────────────────────────────────────────────────────── */
@ -1445,7 +1422,7 @@ function MessageDialogPageContent() {
) : displayMessages.length === 0 ? (
<p className="app-text-muted text-center text-sm">Пока нет сообщений. Напишите первым.</p>
) : (
<div className="flex min-h-full flex-col justify-end">
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col justify-end">
{cm.loadingOlder ? <p className="app-text-muted mb-3 text-center text-xs">Подгружаем старые сообщения...</p> : null}
{displayMessages.map((message, index) => {
const replyToId = getReplyToId(message);
@ -1515,11 +1492,11 @@ function MessageDialogPageContent() {
{/* Scroll-to-bottom button */}
{scroll.showScrollToBottom && (
<div className="pointer-events-auto absolute bottom-25 right-3 z-20 transition-opacity duration-300">
<div className="pointer-events-none absolute bottom-25 left-1/2 z-20 flex w-full max-w-[64rem] -translate-x-1/2 justify-end px-3 transition-opacity duration-300">
<button
type="button"
onClick={() => void handleReturnToNewMessages()}
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border-subtle)] bg-[var(--surface-primary)] p-0 leading-none text-[var(--muted-foreground)] shadow-[0_10px_30px_rgba(15,23,42,0.22)] transition hover:bg-[var(--surface-secondary)] hover:text-[var(--foreground)] active:scale-[0.98]"
className="pointer-events-auto relative inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border-subtle)] bg-[var(--surface-primary)] p-0 leading-none text-[var(--muted-foreground)] shadow-[0_10px_30px_rgba(15,23,42,0.22)] transition hover:bg-[var(--surface-secondary)] hover:text-[var(--foreground)] active:scale-[0.98]"
title="Вернуться к новым сообщениям"
aria-label={displayedUnreadCount > 0 ? `Вернуться к новым сообщениям (${displayedUnreadCount > 99 ? "99+" : displayedUnreadCount})` : "Вернуться к новым сообщениям"}
>
@ -1535,46 +1512,48 @@ function MessageDialogPageContent() {
{/* Composer area */}
<div className="app-divider shrink-0 border-t bg-transparent px-4 pt-3 pb-[calc(env(safe-area-inset-bottom)+0.25rem)] lg:app-header lg:px-0 lg:pb-0">
{typingIndicatorText && (
<div className="app-text-muted mb-2 flex items-center gap-2 text-xs italic">
<div className="flex gap-1">
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "0ms" }} />
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "150ms" }} />
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "300ms" }} />
<div className="mx-auto w-full max-w-[64rem]">
{typingIndicatorText && (
<div className="app-text-muted mb-2 flex items-center gap-2 text-xs italic">
<div className="flex gap-1">
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "0ms" }} />
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "150ms" }} />
<span className="app-text-muted inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-current" style={{ animationDelay: "300ms" }} />
</div>
<span>{typingIndicatorText}</span>
</div>
<span>{typingIndicatorText}</span>
</div>
)}
)}
<MessageComposer
canSendMessages={canSendMessages}
membershipRole={myMembership?.role}
editingMessageId={editingMessageId}
replyTo={replyTo}
attachedFiles={attachedFiles}
messageText={messageText}
sending={sendingComposer}
showEmojiPicker={showComposerEmojiPicker}
allReactions={ALL_REACTIONS}
fileInputRef={fileInputRef}
messageInputRef={messageInputRef}
onPickFiles={handlePickFiles}
onAddFiles={addAttachedFiles}
onFilesChange={handleFilesChange}
onRemoveFile={removeAttachedFile}
onToggleEmojiPicker={() => {
setShowComposerEmojiPicker(prev => !prev);
setExpandedReplyActionForId(null);
setReactionPickerForMessageId(null);
}}
onSelectEmoji={(emoji) => { appendEmojiToComposer(emoji); setShowComposerEmojiPicker(false); }}
onInputClick={() => setShowComposerEmojiPicker(false)}
onChangeMessage={setMessageText}
onTyping={sendTyping}
onSend={handleSend}
onCancelEdit={handleCancelEdit}
onCancelReply={() => setReplyTo(null)}
/>
<MessageComposer
canSendMessages={canSendMessages}
membershipRole={myMembership?.role}
editingMessageId={editingMessageId}
replyTo={replyTo}
attachedFiles={attachedFiles}
messageText={messageText}
sending={sendingComposer}
showEmojiPicker={showComposerEmojiPicker}
allReactions={ALL_REACTIONS}
fileInputRef={fileInputRef}
messageInputRef={messageInputRef}
onPickFiles={handlePickFiles}
onAddFiles={addAttachedFiles}
onFilesChange={handleFilesChange}
onRemoveFile={removeAttachedFile}
onToggleEmojiPicker={() => {
setShowComposerEmojiPicker(prev => !prev);
setExpandedReplyActionForId(null);
setReactionPickerForMessageId(null);
}}
onSelectEmoji={(emoji) => { appendEmojiToComposer(emoji); setShowComposerEmojiPicker(false); }}
onInputClick={() => setShowComposerEmojiPicker(false)}
onChangeMessage={setMessageText}
onTyping={sendTyping}
onSend={handleSend}
onCancelEdit={handleCancelEdit}
onCancelReply={() => setReplyTo(null)}
/>
</div>
</div>
</div>
</>

View File

@ -5598,7 +5598,7 @@ function TasksPageContent() {
className={`${mobileBoardOpen ? "hidden" : "flex"} h-[calc(100dvh-7rem)] w-full ${desktopWideMode ? "lg:flex lg:h-full lg:shrink-0" : ""}`}
/>
<div className={`${mobileBoardOpen ? "flex" : "hidden"} relative min-h-0 min-w-0 flex-1 flex-col gap-4 ${desktopWideMode ? "lg:flex lg:h-full" : "xl:flex xl:h-full"}`}>
<div className={`${mobileBoardOpen ? "flex" : "hidden"} relative min-h-0 min-w-0 flex-1 flex-col gap-4 ${desktopWideMode ? "lg:flex lg:h-full" : ""}`}>
<section className="app-surface relative z-30 shrink-0 overflow-visible rounded-2xl p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">

View File

@ -155,7 +155,11 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
/**
* Восстанавливает позицию скролла ПОСЛЕ обновления React
*/
componentDidUpdate(_prevProps: ScrollableMessageListProps, _prevState: any, snapshot: Snapshot | null) {
componentDidUpdate(
_prevProps: ScrollableMessageListProps,
_prevState: Readonly<Record<string, never>>,
snapshot: Snapshot | null,
) {
if (!snapshot) return;
const container = this.containerRef.current;
@ -337,8 +341,10 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
const container = this.containerRef.current;
if (!container) return;
// Принудительно ставим scrollTop на максимум — самый надёжный способ
container.scrollTop = container.scrollHeight;
container.scrollTo({
top: container.scrollHeight,
behavior: scrollBehavior === 'smooth' ? 'auto' : scrollBehavior,
});
this.lastClientHeight = container.clientHeight;
this.noScroll = true;
@ -369,6 +375,7 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
style={{
overflowY: 'auto',
overflowX: 'hidden',
overscrollBehaviorY: 'contain',
height: '100%',
position: 'relative',
}}
@ -384,7 +391,7 @@ export class ScrollableMessageListInner extends Component<ScrollableMessageListP
// ForwardRef обертка для использования с useRef в функциональных компонентах
export const ScrollableMessageList = forwardRef<ScrollableMessageListInner, ScrollableMessageListProps>(
(props, ref) => {
return <ScrollableMessageListInner ref={ref as any} {...props} />;
return <ScrollableMessageListInner ref={ref} {...props} />;
}
);

View File

@ -223,7 +223,13 @@ export default function ChatMessageItem({
</div>
) : null}
<div className={`flex min-w-0 items-start gap-1 ${isMine ? "max-w-[88%] flex-row-reverse" : "max-w-[calc(100%-2.5rem)]"}`}>
<div
className={`flex w-fit min-w-0 items-start gap-1 sm:max-w-[min(78%,46rem)] ${
isMine
? "max-w-[88%] flex-row-reverse"
: "max-w-[calc(100%-2.5rem)]"
}`}
>
<div className={`relative min-w-0 rounded-2xl px-3 py-2 pr-9 transition-shadow duration-300 ${isMine ? "bg-sky-500 text-white" : "app-surface-elevated text-[var(--foreground)]"} ${isHighlighted ? (isMine ? "shadow-[0_0_0_3px_rgba(125,211,252,0.45)]" : "ring-2 ring-sky-300 shadow-[0_0_0_4px_rgba(186,230,253,0.28)]") : ""}`}>
{hasActions ? (
<div className="absolute right-1 top-1 z-20">
@ -296,7 +302,9 @@ export default function ChatMessageItem({
{message.is_deleted ? (
<p className={`italic text-sm ${isMine ? "text-sky-100" : "app-text-muted"}`}>Сообщение удалено</p>
) : message.content ? (
<p className="whitespace-pre-wrap break-words text-sm leading-5">{message.content}</p>
<p className="whitespace-pre-wrap break-words text-sm leading-5 [overflow-wrap:anywhere]">
{message.content}
</p>
) : null}
{message.attachments && message.attachments.length > 0 ? (

View File

@ -114,7 +114,7 @@ export default function MessagesFocusSidebar({
<MessagesChatListControls userId={user?.id} chatTypeCounts={chatTypeCounts} />
</div> : null}
<div className={`min-h-0 flex-1 space-y-2 overflow-y-auto pb-4 ${sidebarCollapsed ? "px-2 pt-2" : "px-4"}`}>
<div className={`min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-y-contain pb-4 ${sidebarCollapsed ? "px-2 pt-2" : "px-4"}`}>
{loading ? (
<p className="app-text-muted px-3 py-6 text-center text-xs">Загрузка чатов...</p>
) : visibleChats.length === 0 ? (