- Восстановлен мягкий цвет пузырей: color-mix(92%) вместо жесткого var(--bs-primary) - Аватары: border-radius 9999px + grid layout для идеальных кругов - Разделители: правильный spacing, font-weight 700, pointer-events, color-mix прозрачность - Добавлены оптимизации скролла: rootMargin 300px, limit 50 сообщений (Telegram-стиль) - Добавлены новые модули: messageLoader, scrollManager, chatUtils - Рефакторинг логики загрузки сообщений для предотвращения дубликатов - Создана документация по архитектуре и тестированию
524 lines
22 KiB
JavaScript
524 lines
22 KiB
JavaScript
/**
|
||
* @fileoverview ScrollManager - централизованное управление прокруткой
|
||
* @module managers/scrollManager
|
||
*
|
||
* Отвечает за ВСЮ логику скролла в чате:
|
||
* - Автоматическая загрузка истории при scroll up
|
||
* - Умный scroll to bottom без прыжков
|
||
* - Определение "внизу ли пользователь"
|
||
* - Сохранение позиции при загрузке истории
|
||
*/
|
||
|
||
/**
|
||
* ScrollManager - управление прокруткой чата
|
||
*/
|
||
export class ScrollManager {
|
||
/**
|
||
* @param {Object} options - Опции конфигурации
|
||
* @param {HTMLElement} options.scrollElement - Контейнер для скролла
|
||
* @param {MessageLoader} options.messageLoader - Загрузчик сообщений
|
||
* @param {MessageRenderer} options.messageRenderer - Рендерер сообщений
|
||
* @param {MessageStore} options.messageStore - Store сообщений
|
||
* @param {number} options.chatId - ID текущего чата
|
||
*/
|
||
constructor(options) {
|
||
if (!options.scrollElement) {
|
||
throw new Error('[ScrollManager] scrollElement is required');
|
||
}
|
||
if (!options.messageLoader) {
|
||
throw new Error('[ScrollManager] messageLoader is required');
|
||
}
|
||
if (!options.messageRenderer) {
|
||
throw new Error('[ScrollManager] messageRenderer is required');
|
||
}
|
||
|
||
this.scrollEl = options.scrollElement;
|
||
this.loader = options.messageLoader;
|
||
this.renderer = options.messageRenderer;
|
||
this.store = options.messageStore;
|
||
this.chatId = options.chatId;
|
||
|
||
/** @type {IntersectionObserver|null} */
|
||
this.historyObserver = null;
|
||
|
||
/** @type {boolean} */
|
||
this.isLoadingHistory = false;
|
||
|
||
/** @type {number} Порог в пикселях для определения "внизу" */
|
||
this.bottomThreshold = 100;
|
||
|
||
console.log('[ScrollManager] Initialized for chat:', this.chatId);
|
||
}
|
||
|
||
// ==================== Инициализация ====================
|
||
|
||
/**
|
||
* Инициализирует ScrollManager
|
||
*/
|
||
async init() {
|
||
// Даём время на завершение рендера всех сообщений
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
this.setupIntersectionObserver();
|
||
this.setupScrollListener();
|
||
console.log('[ScrollManager] Initialized');
|
||
}
|
||
|
||
/**
|
||
* Останавливает ScrollManager и очищает ресурсы
|
||
*/
|
||
destroy() {
|
||
if (this.historyObserver) {
|
||
this.historyObserver.disconnect();
|
||
this.historyObserver = null;
|
||
}
|
||
console.log('[ScrollManager] Destroyed');
|
||
}
|
||
|
||
// ==================== IntersectionObserver для автозагрузки ====================
|
||
|
||
/**
|
||
* Настраивает IntersectionObserver для автоматической загрузки истории
|
||
*/
|
||
setupIntersectionObserver() {
|
||
console.log('[ScrollManager] ========================================');
|
||
console.log('[ScrollManager] setupIntersectionObserver called');
|
||
|
||
// Отключаем старый observer если есть
|
||
if (this.historyObserver) {
|
||
console.log('[ScrollManager] Disconnecting old observer');
|
||
this.historyObserver.disconnect();
|
||
this.historyObserver = null;
|
||
}
|
||
|
||
// Находим первое сообщение (просто .msg - найдет первый с этим классом)
|
||
const firstMessage = this.scrollEl.querySelector('.msg');
|
||
|
||
console.log('[ScrollManager] First message element:', !!firstMessage);
|
||
console.log('[ScrollManager] Total messages in DOM:', this.scrollEl.querySelectorAll('.msg').length);
|
||
|
||
// Логируем реальные классы первых 3 элементов в контейнере
|
||
const allChildren = this.scrollEl.children;
|
||
console.log('[ScrollManager] First 3 elements in container:');
|
||
for (let i = 0; i < Math.min(3, allChildren.length); i++) {
|
||
const el = allChildren[i];
|
||
console.log(` [${i}]:`, {
|
||
tag: el.tagName,
|
||
classes: el.className,
|
||
messageId: el.dataset?.messageId
|
||
});
|
||
}
|
||
|
||
if (!firstMessage) {
|
||
console.warn('[ScrollManager] ❌ No messages yet, observer NOT set');
|
||
console.log('[ScrollManager] ========================================');
|
||
return;
|
||
}
|
||
|
||
console.log('[ScrollManager] First message ID:', firstMessage.dataset?.messageId);
|
||
|
||
// Создаем observer
|
||
this.historyObserver = new IntersectionObserver(
|
||
entries => {
|
||
entries.forEach(entry => {
|
||
console.log('[ScrollManager] IntersectionObserver triggered:', {
|
||
isIntersecting: entry.isIntersecting,
|
||
isLoadingHistory: this.isLoadingHistory,
|
||
target: entry.target.dataset?.messageId
|
||
});
|
||
|
||
if (entry.isIntersecting && !this.isLoadingHistory) {
|
||
console.log('[ScrollManager] ✅ First message visible, loading history...');
|
||
this.loadMoreHistory();
|
||
}
|
||
});
|
||
},
|
||
{
|
||
root: this.scrollEl,
|
||
threshold: 0.1,
|
||
rootMargin: '300px 0px 0px 0px' // Telegram-стиль: загружаем намного раньше
|
||
}
|
||
);
|
||
|
||
this.historyObserver.observe(firstMessage);
|
||
console.log('[ScrollManager] ✅ IntersectionObserver attached to first message');
|
||
console.log('[ScrollManager] ========================================');
|
||
}
|
||
|
||
/**
|
||
* Обновляет observer на новое первое сообщение
|
||
*/
|
||
updateObserverTarget() {
|
||
console.log('[ScrollManager] ========================================');
|
||
console.log('[ScrollManager] updateObserverTarget called');
|
||
|
||
if (!this.historyObserver) {
|
||
console.warn('[ScrollManager] No observer exists');
|
||
console.log('[ScrollManager] ========================================');
|
||
return;
|
||
}
|
||
|
||
// Отключаем от всех
|
||
this.historyObserver.disconnect();
|
||
|
||
// Находим РЕАЛЬНОЕ первое сообщение с data-message-id
|
||
// Важно: после добавления истории в начало, ПЕРВОЕ сообщение в DOM - это самое старое
|
||
const firstMessage = this.scrollEl.querySelector('.msg[data-message-id]');
|
||
|
||
console.log('[ScrollManager] Found first message:', !!firstMessage);
|
||
if (firstMessage) {
|
||
console.log('[ScrollManager] First message ID:', firstMessage.dataset.messageId);
|
||
this.historyObserver.observe(firstMessage);
|
||
console.log('[ScrollManager] ✅ Observer updated to new first message');
|
||
} else {
|
||
console.warn('[ScrollManager] ❌ No first message found in DOM');
|
||
}
|
||
console.log('[ScrollManager] ========================================');
|
||
}
|
||
|
||
// ==================== Загрузка истории ====================
|
||
|
||
/**
|
||
* Загружает больше истории при scroll up
|
||
*/
|
||
async loadMoreHistory() {
|
||
console.log('[ScrollManager] ========================================');
|
||
console.log('[ScrollManager] loadMoreHistory called');
|
||
console.log('[ScrollManager] isLoadingHistory:', this.isLoadingHistory);
|
||
|
||
if (this.isLoadingHistory) {
|
||
console.log('[ScrollManager] ❌ Already loading history');
|
||
console.log('[ScrollManager] ========================================');
|
||
return;
|
||
}
|
||
|
||
const hasMore = this.loader.hasMoreHistory(this.chatId);
|
||
console.log('[ScrollManager] hasMoreHistory:', hasMore);
|
||
|
||
if (!hasMore) {
|
||
console.log('[ScrollManager] ❌ No more history available');
|
||
console.log('[ScrollManager] ========================================');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this.isLoadingHistory = true;
|
||
|
||
// ============================================
|
||
// ПРАВИЛЬНЫЙ подход из Telegram Web:
|
||
// 1. Сохраняем scrollHeight и scrollTop ДО добавления элементов
|
||
// 2. Prepend элементы в начало (НЕ полный re-render!)
|
||
// 3. Вычисляем разницу scrollHeight
|
||
// 4. Корректируем scrollTop на эту разницу
|
||
// ============================================
|
||
|
||
// Отключаем smooth scroll чтобы изменения были мгновенными
|
||
const originalScrollBehavior = this.scrollEl.style.scrollBehavior;
|
||
this.scrollEl.style.scrollBehavior = 'auto';
|
||
|
||
const oldScrollHeight = this.scrollEl.scrollHeight;
|
||
const oldScrollTop = this.scrollEl.scrollTop;
|
||
|
||
console.log('[ScrollManager] ✅ Loading history...', {
|
||
oldScrollHeight,
|
||
oldScrollTop
|
||
});
|
||
|
||
// Загружаем историю через Loader (возвращает уже загруженные сообщения)
|
||
const messages = await this.loader.loadHistoryBefore(this.chatId);
|
||
|
||
// Проверяем есть ли еще история ПОСЛЕ загрузки
|
||
const stillHasMore = this.loader.hasMoreHistory(this.chatId);
|
||
|
||
if (messages.length === 0 || !stillHasMore) {
|
||
console.log('[ScrollManager] ⚠️ No more unique history to load');
|
||
console.log('[ScrollManager] ========================================');
|
||
this.isLoadingHistory = false;
|
||
return;
|
||
}
|
||
|
||
console.log('[ScrollManager] ✅ History loaded:', messages.length, 'messages');
|
||
|
||
// КЛЮЧЕВОЙ МОМЕНТ: НЕ делаем полный render!
|
||
// Создаем fragment с новыми сообщениями
|
||
const fragment = this.renderer.prependMessages(messages, this.chatId);
|
||
|
||
// Если fragment пустой (все сообщения уже отрендерены), выходим
|
||
if (!fragment.hasChildNodes()) {
|
||
console.log('[ScrollManager] ⚠️ All messages already rendered');
|
||
this.scrollEl.style.scrollBehavior = originalScrollBehavior;
|
||
this.isLoadingHistory = false;
|
||
return;
|
||
}
|
||
|
||
// ВАЖНО: Проверяем и удаляем потенциальные дубликаты divider
|
||
// ПЕРЕД prepend, чтобы избежать визуальных скачков
|
||
const firstFragmentChild = fragment.firstElementChild;
|
||
if (firstFragmentChild && firstFragmentChild.classList.contains('day-divider')) {
|
||
const firstContainerChild = this.scrollEl.firstElementChild;
|
||
if (firstContainerChild && firstContainerChild.classList.contains('day-divider')) {
|
||
const fragmentDividerText = firstFragmentChild.textContent.trim();
|
||
const containerDividerText = firstContainerChild.textContent.trim();
|
||
|
||
// Если dividers одинаковые, удаляем из fragment
|
||
if (fragmentDividerText === containerDividerText) {
|
||
fragment.removeChild(firstFragmentChild);
|
||
console.log('[ScrollManager] Removed duplicate divider before prepend:', fragmentDividerText);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Prepend fragment в НАЧАЛО контейнера (как в Telegram)
|
||
this.scrollEl.prepend(fragment);
|
||
|
||
// КЛЮЧЕВОЕ ОТЛИЧИЕ Telegram: ДВОЙНОЙ RAF для стабильности!
|
||
// Первый RAF - браузер обновляет layout
|
||
// Второй RAF - браузер гарантированно применил все изменения
|
||
await new Promise(resolve => {
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
// КЛЮЧЕВОЙ МОМЕНТ: вычисляем разницу в высоте
|
||
const newScrollHeight = this.scrollEl.scrollHeight;
|
||
const heightDifference = newScrollHeight - oldScrollHeight;
|
||
|
||
// Корректируем scrollTop на разницу высоты
|
||
// Это сохраняет визуальную позицию пользователя ТОЧНО там же
|
||
// БЕЗ КАКИХ-ЛИБО ПРЫЖКОВ!
|
||
this.scrollEl.scrollTop = oldScrollTop + heightDifference;
|
||
|
||
console.log('[ScrollManager] Scroll restored using height difference:', {
|
||
oldScrollHeight,
|
||
newScrollHeight,
|
||
heightDifference,
|
||
oldScrollTop,
|
||
newScrollTop: this.scrollEl.scrollTop
|
||
});
|
||
|
||
// Обновляем observer на новое первое сообщение
|
||
this.updateObserverTarget();
|
||
|
||
// Восстанавливаем scroll-behavior
|
||
this.scrollEl.style.scrollBehavior = originalScrollBehavior;
|
||
|
||
resolve();
|
||
});
|
||
});
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('[ScrollManager] Failed to load history:', error);
|
||
} finally {
|
||
this.isLoadingHistory = false;
|
||
}
|
||
}
|
||
|
||
// ==================== Scroll управление ====================
|
||
|
||
/**
|
||
* Настраивает слушатель скролла (для будущих фич)
|
||
*/
|
||
setupScrollListener() {
|
||
this.scrollEl.addEventListener('scroll', () => {
|
||
// Здесь можно добавить логику, например:
|
||
// - Показать/скрыть кнопку "scroll to bottom"
|
||
// - Отметка сообщений как прочитанных
|
||
// - etc.
|
||
}, { passive: true });
|
||
}
|
||
|
||
/**
|
||
* Прокручивает чат в самый низ
|
||
* @param {Object} options - Опции
|
||
* @param {boolean} [options.instant=false] - Мгновенная прокрутка без анимации
|
||
* @param {boolean} [options.force=false] - Прокрутить даже если пользователь не внизу
|
||
*/
|
||
scrollToBottom(options = {}) {
|
||
const { instant = false, force = false } = options;
|
||
|
||
// Не скроллим если загружается история (кроме force)
|
||
if (!force && this.isLoadingHistory) {
|
||
console.log('[ScrollManager] History is loading, not scrolling to bottom');
|
||
return;
|
||
}
|
||
|
||
// Не скроллим если пользователь читает историю (кроме force)
|
||
if (!force && !this.isNearBottom()) {
|
||
console.log('[ScrollManager] User is reading history, not scrolling');
|
||
return;
|
||
}
|
||
|
||
console.log('[ScrollManager] Scrolling to bottom:', { instant, force });
|
||
|
||
if (instant) {
|
||
// Мгновенная прокрутка - используем visibility hiding
|
||
this.scrollEl.style.visibility = 'hidden';
|
||
|
||
// Двойной RAF для гарантии после layout
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;
|
||
this.scrollEl.style.visibility = '';
|
||
console.log('[ScrollManager] Instant scroll complete');
|
||
});
|
||
});
|
||
} else {
|
||
// Плавная прокрутка
|
||
requestAnimationFrame(() => {
|
||
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;
|
||
console.log('[ScrollManager] Smooth scroll complete');
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Прокручивает к конкретному сообщению
|
||
* @param {number|string} messageId - ID сообщения
|
||
* @param {Object} options - Опции
|
||
* @param {string} [options.block='center'] - Положение ('start', 'center', 'end', 'nearest')
|
||
* @param {string} [options.behavior='smooth'] - Поведение ('auto', 'smooth')
|
||
*/
|
||
scrollToMessage(messageId, options = {}) {
|
||
const messageEl = this.scrollEl.querySelector(`[data-message-id="${messageId}"]`);
|
||
|
||
if (!messageEl) {
|
||
console.warn('[ScrollManager] Message not found:', messageId);
|
||
return;
|
||
}
|
||
|
||
messageEl.scrollIntoView({
|
||
block: options.block || 'center',
|
||
behavior: options.behavior || 'smooth'
|
||
});
|
||
|
||
console.log('[ScrollManager] Scrolled to message:', messageId);
|
||
}
|
||
|
||
// ==================== Утилиты ====================
|
||
|
||
/**
|
||
* Проверяет находится ли пользователь близко к низу
|
||
* @param {number} [threshold] - Порог в пикселях (по умолчанию this.bottomThreshold)
|
||
* @returns {boolean}
|
||
*/
|
||
isNearBottom(threshold = null) {
|
||
const th = threshold !== null ? threshold : this.bottomThreshold;
|
||
const isNear = (
|
||
this.scrollEl.scrollTop + this.scrollEl.clientHeight >=
|
||
this.scrollEl.scrollHeight - th
|
||
);
|
||
return isNear;
|
||
}
|
||
|
||
/**
|
||
* Проверяет находится ли пользователь в самом верху
|
||
* @returns {boolean}
|
||
*/
|
||
isAtTop() {
|
||
return this.scrollEl.scrollTop <= 10;
|
||
}
|
||
|
||
/**
|
||
* Получает текущую позицию скролла в процентах
|
||
* @returns {number} От 0 до 100
|
||
*/
|
||
getScrollPercentage() {
|
||
const scrollable = this.scrollEl.scrollHeight - this.scrollEl.clientHeight;
|
||
if (scrollable <= 0) return 100;
|
||
|
||
return (this.scrollEl.scrollTop / scrollable) * 100;
|
||
}
|
||
|
||
/**
|
||
* Сохраняет текущую позицию скролла
|
||
* @returns {Object} Сохраненное состояние
|
||
*/
|
||
saveScrollPosition() {
|
||
return {
|
||
scrollTop: this.scrollEl.scrollTop,
|
||
scrollHeight: this.scrollEl.scrollHeight,
|
||
clientHeight: this.scrollEl.clientHeight
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Восстанавливает позицию скролла
|
||
* @param {Object} savedPosition - Сохраненное состояние
|
||
*/
|
||
restoreScrollPosition(savedPosition) {
|
||
if (!savedPosition) return;
|
||
|
||
const delta = this.scrollEl.scrollHeight - savedPosition.scrollHeight;
|
||
this.scrollEl.scrollTop = savedPosition.scrollTop + delta;
|
||
|
||
console.log('[ScrollManager] Position restored:', {
|
||
delta,
|
||
newScrollTop: this.scrollEl.scrollTop
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Получает первое видимое сообщение (элемент DOM)
|
||
* @private
|
||
* @returns {HTMLElement|null}
|
||
*/
|
||
_getFirstVisibleMessage() {
|
||
const messages = this.scrollEl.querySelectorAll('.msg[data-message-id]');
|
||
const containerRect = this.scrollEl.getBoundingClientRect();
|
||
|
||
for (const msg of messages) {
|
||
const rect = msg.getBoundingClientRect();
|
||
if (rect.top >= containerRect.top && rect.top <= containerRect.bottom) {
|
||
return msg;
|
||
}
|
||
}
|
||
|
||
return messages[0] || null; // Если не нашли видимое, берем первое
|
||
}
|
||
|
||
/**
|
||
* Получает ID первого видимого сообщения
|
||
* @returns {number|string|null}
|
||
*/
|
||
getFirstVisibleMessageId() {
|
||
const msg = this._getFirstVisibleMessage();
|
||
return msg ? msg.dataset.messageId : null;
|
||
}
|
||
|
||
/**
|
||
* Получает ID последнего видимого сообщения
|
||
* @returns {number|string|null}
|
||
*/
|
||
getLastVisibleMessageId() {
|
||
const messages = Array.from(this.scrollEl.querySelectorAll('.msg[data-message-id]'));
|
||
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const msg = messages[i];
|
||
const rect = msg.getBoundingClientRect();
|
||
const containerRect = this.scrollEl.getBoundingClientRect();
|
||
|
||
if (rect.bottom >= containerRect.top && rect.bottom <= containerRect.bottom) {
|
||
return msg.dataset.messageId;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Получает статус ScrollManager
|
||
* @returns {Object}
|
||
*/
|
||
getStatus() {
|
||
return {
|
||
isNearBottom: this.isNearBottom(),
|
||
isAtTop: this.isAtTop(),
|
||
scrollPercentage: this.getScrollPercentage(),
|
||
isLoadingHistory: this.isLoadingHistory,
|
||
firstVisibleMessage: this.getFirstVisibleMessageId(),
|
||
lastVisibleMessage: this.getLastVisibleMessageId()
|
||
};
|
||
}
|
||
}
|
||
|
||
export default ScrollManager;
|