diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index 20c5ab0..97561ec 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -230,3 +230,17 @@ - Как проверялось: vitest 96/96; `npm run check` чисто; caller'ы дерева (роут /tree, MCP) — формат не изменился. - Влияние на поиск/UX: нет. - Подводные камни: raw CTE под PostgreSQL — при переименовании колонок tasks/forms обновить вручную (drizzle не проверяет); getRelatedTasks (граф task_relations) сознательно оставлен — кандидат на отдельный шаг. + +--- + +## [1.3] Виртуализация списков (@tanstack/react-virtual) + +- Статус: ✅ done +- Зачем: длинные реестры/чаты не рендерят тысячи DOM-узлов (Фаза 1). +- Что изменено: + - `@tanstack/react-virtual@3.14.11` в dependencies (+6-7 kB gzip). + - `client/src/components/TaskRegistry.tsx` — виртуализация строк таблицы (padding-строки в нативном tbody, динамическое измерение высоты через ResizeObserver, overscan 10, getItemKey=task.id). Порог 30 строк — меньше рендер 1:1 прежний. Массовое выделение (Shift+клик через anchorTaskId), сортировка, мгновенный клиентский поиск (инвариант), DnD колонок, inline-редакторы — работают по полному массиву, не по DOM. + - `client/src/components/TaskChat.tsx` — виртуализация сообщений (плоский список: дата-разделители + сообщения, динамическая высота, overscan 10). Порог 100 элементов. Автоскролл к новому, scrollToMessage с fallback scrollToIndex, оптимистичная отправка, реакции/опросы/вложения сохранены. +- Как проверялось: `npm run check` чисто; vitest 96/96; `npm run build` собирается. +- Влияние на поиск/UX: поиск в реестре мгновенный как и был (фильтрация до виртуализации). +- Подводные камни: в виртуальном чате дата-разделители не sticky; возможна «игра» ширин колонок реестра при table-layout:auto; уход редактируемой строки за overscan размонтирует несохранённый ввод (стандарт виртсписков). Ручная проверка в браузере: реестр с длинными значениями, чат >100 сообщений (автоскролл, скролл к ответу). diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx index 37b6313..2bbb941 100644 --- a/client/src/components/TaskChat.tsx +++ b/client/src/components/TaskChat.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'; import { useQuery, useMutation } from '@tanstack/react-query'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { useForm } from 'react-hook-form'; import { useLocation } from 'wouter'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -186,6 +187,15 @@ const formatDateHeader = (date: Date) => { } }; +// Порог виртуализации чата: при меньшем числе элементов (сообщения + разделители дат) +// рендерим как обычно, чтобы не менять поведение коротких чатов +const CHAT_VIRTUALIZATION_THRESHOLD = 100; + +// Элемент виртуализированного списка чата: разделитель даты или сообщение +type ChatListItem = + | { type: 'header'; key: string; date: Date } + | { type: 'message'; key: string; message: TaskMessage }; + const TaskChat = ({ taskId }: TaskChatProps) => { const { toast } = useToast(); const { user } = useAuth(); @@ -359,6 +369,28 @@ const TaskChat = ({ taskId }: TaskChatProps) => { const messages: TaskMessage[] = (messagesData as any)?.messages || []; const groupedMessages = groupMessagesByDate(messages); + // Плоский список для виртуализации: разделители дат + сообщения + const chatItems: ChatListItem[] = []; + groupedMessages.forEach((group, groupIndex) => { + chatItems.push({ type: 'header', key: `header-${groupIndex}-${format(group.date, 'yyyy-MM-dd')}`, date: group.date }); + group.messages.forEach(message => { + chatItems.push({ type: 'message', key: `msg-${message.id}`, message }); + }); + }); + + // Виртуализация длинных чатов: рендерятся только видимые сообщения + overscan. + // Динамическая высота измеряется через measureElement (ResizeObserver). + const shouldVirtualizeChat = chatItems.length > CHAT_VIRTUALIZATION_THRESHOLD; + const chatVirtualizer = useVirtualizer({ + count: chatItems.length, + getScrollElement: () => + (scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement | null) ?? null, + estimateSize: (index) => (chatItems[index]?.type === 'header' ? 28 : 64), + overscan: 10, + getItemKey: (index) => chatItems[index]?.key ?? index, + enabled: shouldVirtualizeChat, + }); + // Загрузка реакций и опросов (batch) useQuery({ queryKey: ['/api/tasks', taskId, 'reactions'], @@ -388,17 +420,35 @@ const TaskChat = ({ taskId }: TaskChatProps) => { // Функция для скролла к оригинальному сообщению const scrollToMessage = (messageId: number) => { + const highlightElement = (el: Element) => { + // Подсвечиваем сообщение на короткое время с помощью стилей + el.classList.add('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500'); + setTimeout(() => { + el.classList.remove('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500'); + }, 2000); + }; + const messageElement = document.querySelector(`[data-message-id="${messageId}"]`); if (messageElement) { - messageElement.scrollIntoView({ - behavior: 'smooth', - block: 'center' + messageElement.scrollIntoView({ + behavior: 'smooth', + block: 'center' }); - // Подсвечиваем сообщение на короткое время с помощью стилей - messageElement.classList.add('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500'); - setTimeout(() => { - messageElement.classList.remove('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500'); - }, 2000); + highlightElement(messageElement); + return; + } + + // Виртуализированный список: целевое сообщение может быть не отрендерено — + // прокручиваем к его индексу через virtualizer и подсвечиваем после рендера + if (shouldVirtualizeChat) { + const index = chatItems.findIndex(item => item.type === 'message' && item.message.id === messageId); + if (index >= 0) { + chatVirtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' }); + setTimeout(() => { + const el = document.querySelector(`[data-message-id="${messageId}"]`); + if (el) highlightElement(el); + }, 400); + } } }; @@ -432,6 +482,11 @@ const TaskChat = ({ taskId }: TaskChatProps) => { // Функция для прокрутки к последнему сообщению const scrollToBottom = () => { + if (shouldVirtualizeChat) { + // В виртуализированном списке прокручиваем к последнему элементу через virtualizer + chatVirtualizer.scrollToIndex(chatItems.length - 1, { align: 'end' }); + return; + } const scrollElement = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]'); if (scrollElement) { scrollElement.scrollTop = scrollElement.scrollHeight; @@ -1318,6 +1373,308 @@ const TaskChat = ({ taskId }: TaskChatProps) => { return ; }; + // Рендер одного сообщения (общий для обычного и виртуализированного списка) + const renderMessage = (message: TaskMessage) => { + const isMyMessage = user && message.author && message.author.id === user.id; + return ( +
+ {isMyMessage ? ( + // Мое сообщение - справа без аватара как в WhatsApp +
+
+
+ {/* Показываем информацию о родительском сообщении если это ответ */} + {message.replyToMessage && ( +
{ + if (message.replyToMessage?.id) { + scrollToMessage(message.replyToMessage.id); + } + }} + > +
+ Ответ на сообщение {message.replyToMessage.author.firstName} {message.replyToMessage.author.middleName || ''} {message.replyToMessage.author.lastName} +
+
+ {message.replyToMessage.message.length > 50 + ? message.replyToMessage.message.substring(0, 50) + '...' + : message.replyToMessage.message} +
+
+ )} + + {/* Текст сообщения или форма редактирования */} + {editingMessage?.id === message.id ? ( +
+