- проблема: Observer срабатывал сразу после setup даже с проверкой isNearBottom - isNearBottom() возвращает false когда scrollTop=0 (пользователь вверху) - решение: игнорируем события Observer в течение 500ms после init - флаг _isInitializing снимается через setTimeout - это гарантирует что автозагрузка не произойдет при первом рендере
669 lines
24 KiB
JavaScript
669 lines
24 KiB
JavaScript
/**
|
||
* @fileoverview ScrollManager V2 - оптимизированное управление прокруткой
|
||
* @module managers/scrollManagerV2
|
||
*
|
||
* РЕФАКТОРИНГ:
|
||
* - Debounce/throttle для scroll events
|
||
* - Оптимизированный IntersectionObserver
|
||
* - Чистый API без дублирования кода
|
||
* - Поддержка виртуализации (будущее)
|
||
*/
|
||
|
||
import { SCROLL_CONFIG, LOADER_CONFIG } from '../config/chatConfig.js';
|
||
|
||
/**
|
||
* Создает debounced функцию
|
||
* @param {Function} fn - Функция для debounce
|
||
* @param {number} delay - Задержка в мс
|
||
* @returns {Function}
|
||
*/
|
||
function debounce(fn, delay) {
|
||
let timeoutId = null;
|
||
return function(...args) {
|
||
if (timeoutId) clearTimeout(timeoutId);
|
||
timeoutId = setTimeout(() => fn.apply(this, args), delay);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Создает throttled функцию
|
||
* @param {Function} fn - Функция для throttle
|
||
* @param {number} limit - Минимальный интервал в мс
|
||
* @returns {Function}
|
||
*/
|
||
function throttle(fn, limit) {
|
||
let inThrottle = false;
|
||
return function(...args) {
|
||
if (!inThrottle) {
|
||
fn.apply(this, args);
|
||
inThrottle = true;
|
||
setTimeout(() => inThrottle = false, limit);
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* @typedef {Object} ScrollManagerOptions
|
||
* @property {HTMLElement} scrollElement - Контейнер для скролла
|
||
* @property {MessageLoaderV2} loader - Загрузчик сообщений
|
||
* @property {MessageRendererV2} renderer - Рендерер сообщений
|
||
* @property {MessageStore} store - Store сообщений
|
||
* @property {number} chatId - ID чата
|
||
*/
|
||
|
||
/**
|
||
* ScrollManager V2 - управление прокруткой чата
|
||
*/
|
||
export class ScrollManagerV2 {
|
||
/**
|
||
* @param {ScrollManagerOptions} options
|
||
*/
|
||
constructor(options) {
|
||
this._validateOptions(options);
|
||
|
||
this.scrollEl = options.scrollElement;
|
||
this.loader = options.loader;
|
||
this.renderer = options.renderer;
|
||
this.store = options.store;
|
||
this.chatId = options.chatId;
|
||
|
||
// Конфигурация
|
||
this.config = { ...SCROLL_CONFIG };
|
||
|
||
// Состояние
|
||
this._historyObserver = null;
|
||
this._isLoadingHistory = false;
|
||
this._destroyed = false;
|
||
this._isInitializing = true; // ✅ Флаг инициализации
|
||
|
||
// Bound handlers для cleanup
|
||
this._boundScrollHandler = null;
|
||
this._boundResizeHandler = null;
|
||
|
||
// Debounced/throttled функции
|
||
this._debouncedLoadHistory = debounce(
|
||
() => this._triggerLoadHistory(),
|
||
LOADER_CONFIG.HISTORY_DEBOUNCE
|
||
);
|
||
|
||
console.log('[ScrollManagerV2] Created for chat:', this.chatId);
|
||
}
|
||
|
||
// ==================== Публичное API ====================
|
||
|
||
/**
|
||
* Инициализирует ScrollManager
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async init() {
|
||
console.log('[ScrollManagerV2] 🔵 INIT STARTED');
|
||
console.log('[ScrollManagerV2] Initial scroll state:', {
|
||
scrollTop: this.scrollEl.scrollTop,
|
||
scrollHeight: this.scrollEl.scrollHeight,
|
||
clientHeight: this.scrollEl.clientHeight,
|
||
isNearBottom: this.isNearBottom(),
|
||
messagesCount: this.scrollEl.querySelectorAll('.msg').length
|
||
});
|
||
|
||
// Ждем стабилизации DOM
|
||
await this._waitForLayout();
|
||
|
||
console.log('[ScrollManagerV2] After layout wait:', {
|
||
scrollTop: this.scrollEl.scrollTop,
|
||
scrollHeight: this.scrollEl.scrollHeight
|
||
});
|
||
|
||
this._setupIntersectionObserver();
|
||
this._setupScrollListener();
|
||
this._setupResizeListener();
|
||
|
||
// Снимаем флаг инициализации после небольшой задержки
|
||
// чтобы Observer не сработал сразу
|
||
setTimeout(() => {
|
||
this._isInitializing = false;
|
||
console.log('[ScrollManagerV2] ✅ Initialization flag cleared');
|
||
}, 500);
|
||
|
||
console.log('[ScrollManagerV2] 🟢 INIT COMPLETE');
|
||
}
|
||
|
||
/**
|
||
* Уничтожает ScrollManager
|
||
*/
|
||
destroy() {
|
||
if (this._destroyed) return;
|
||
|
||
this._destroyed = true;
|
||
|
||
// Отключаем observer
|
||
if (this._historyObserver) {
|
||
this._historyObserver.disconnect();
|
||
this._historyObserver = null;
|
||
}
|
||
|
||
// Удаляем слушатели
|
||
if (this._boundScrollHandler) {
|
||
this.scrollEl.removeEventListener('scroll', this._boundScrollHandler);
|
||
}
|
||
if (this._boundResizeHandler) {
|
||
window.removeEventListener('resize', this._boundResizeHandler);
|
||
}
|
||
|
||
console.log('[ScrollManagerV2] Destroyed');
|
||
}
|
||
|
||
/**
|
||
* Загружает больше истории
|
||
* @returns {Promise<Array>}
|
||
*/
|
||
async loadMoreHistory() {
|
||
console.log('[ScrollManagerV2] ======================================');
|
||
console.log('[ScrollManagerV2] loadMoreHistory called');
|
||
|
||
if (this._isLoadingHistory || this._destroyed) {
|
||
console.log('[ScrollManagerV2] ❌ Skipped: already loading or destroyed');
|
||
return [];
|
||
}
|
||
|
||
if (!this.loader.hasMoreHistory(this.chatId)) {
|
||
console.log('[ScrollManagerV2] ❌ No more history available');
|
||
return [];
|
||
}
|
||
|
||
this._isLoadingHistory = true;
|
||
|
||
try {
|
||
// Отключаем smooth scroll для мгновенных изменений
|
||
const originalScrollBehavior = this.scrollEl.style.scrollBehavior;
|
||
this.scrollEl.style.scrollBehavior = 'auto';
|
||
|
||
// Сохраняем высоты ДО загрузки
|
||
const oldScrollHeight = this.scrollEl.scrollHeight;
|
||
const oldScrollTop = this.scrollEl.scrollTop;
|
||
const messagesBefore = this.scrollEl.querySelectorAll('.msg[data-message-id]').length;
|
||
|
||
console.log('[ScrollManagerV2] BEFORE load:', {
|
||
oldScrollHeight,
|
||
oldScrollTop,
|
||
messagesBefore
|
||
});
|
||
|
||
// Загружаем историю
|
||
const messages = await this.loader.loadHistory(this.chatId);
|
||
|
||
console.log('[ScrollManagerV2] AFTER loader.loadHistory:', {
|
||
messagesLoaded: messages.length,
|
||
messageIds: messages.map(m => m.id)
|
||
});
|
||
|
||
// Проверяем есть ли еще история ПОСЛЕ загрузки
|
||
const stillHasMore = this.loader.hasMoreHistory(this.chatId);
|
||
|
||
if (messages.length === 0 || !stillHasMore) {
|
||
console.log('[ScrollManagerV2] ⚠️ Stopping: no messages or no more history', {
|
||
messagesLength: messages.length,
|
||
stillHasMore
|
||
});
|
||
this.scrollEl.style.scrollBehavior = originalScrollBehavior;
|
||
return [];
|
||
}
|
||
|
||
console.log('[ScrollManagerV2] ✅ History loaded:', messages.length, 'messages');
|
||
|
||
// КЛЮЧЕВОЙ МОМЕНТ: НЕ делаем полный render!
|
||
// Создаем fragment с новыми сообщениями
|
||
const fragment = this.renderer.prependMessages(messages, this.chatId);
|
||
|
||
console.log('[ScrollManagerV2] Fragment created:', {
|
||
hasChildNodes: fragment.hasChildNodes(),
|
||
childCount: fragment.childElementCount
|
||
});
|
||
|
||
// Если fragment пустой (все сообщения уже отрендерены), выходим
|
||
if (!fragment.hasChildNodes()) {
|
||
console.log('[ScrollManagerV2] ⚠️ All messages already rendered');
|
||
this.scrollEl.style.scrollBehavior = originalScrollBehavior;
|
||
return messages;
|
||
}
|
||
|
||
// ВАЖНО: Проверяем и удаляем потенциальные дубликаты 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('[ScrollManagerV2] Removed duplicate divider:', fragmentDividerText);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Prepend fragment в НАЧАЛО контейнера (как в Telegram)
|
||
this.scrollEl.prepend(fragment);
|
||
|
||
const messagesAfterPrepend = this.scrollEl.querySelectorAll('.msg[data-message-id]').length;
|
||
console.log('[ScrollManagerV2] AFTER prepend:', {
|
||
messagesAfterPrepend,
|
||
added: messagesAfterPrepend - messagesBefore
|
||
});
|
||
|
||
// КЛЮЧЕВОЕ ОТЛИЧИЕ 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('[ScrollManagerV2] Scroll restored using height difference:', {
|
||
oldScrollHeight,
|
||
newScrollHeight,
|
||
heightDifference,
|
||
oldScrollTop,
|
||
newScrollTop: this.scrollEl.scrollTop
|
||
});
|
||
|
||
// Обновляем observer на новое первое сообщение
|
||
this._updateObserverTarget();
|
||
|
||
// Восстанавливаем scroll-behavior
|
||
this.scrollEl.style.scrollBehavior = originalScrollBehavior;
|
||
|
||
const finalMessages = this.scrollEl.querySelectorAll('.msg[data-message-id]').length;
|
||
console.log('[ScrollManagerV2] ✅ FINAL STATE:', {
|
||
finalMessages,
|
||
totalAdded: finalMessages - messagesBefore
|
||
});
|
||
console.log('[ScrollManagerV2] ======================================');
|
||
|
||
resolve();
|
||
});
|
||
});
|
||
});
|
||
|
||
return messages;
|
||
|
||
} catch (error) {
|
||
console.error('[ScrollManagerV2] ❌ History load failed:', error);
|
||
console.log('[ScrollManagerV2] ======================================');
|
||
return [];
|
||
|
||
} finally {
|
||
this._isLoadingHistory = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Прокручивает к низу чата
|
||
* @param {Object} [options]
|
||
* @param {boolean} [options.instant=false] - Без анимации
|
||
* @param {boolean} [options.force=false] - Игнорировать позицию пользователя
|
||
*/
|
||
scrollToBottom(options = {}) {
|
||
const { instant = false, force = false } = options;
|
||
|
||
if (!force && !this.isNearBottom()) {
|
||
return;
|
||
}
|
||
|
||
if (instant) {
|
||
// Мгновенный скролл с visibility hiding
|
||
this.scrollEl.style.visibility = 'hidden';
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;
|
||
this.scrollEl.style.visibility = '';
|
||
});
|
||
});
|
||
} else {
|
||
// Обычный скролл - БЕЗ анимации (как в старом коде)
|
||
// Используем scrollTop вместо scrollTo({ behavior: 'smooth' })
|
||
// чтобы избежать незавершенных анимаций
|
||
requestAnimationFrame(() => {
|
||
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Прокручивает к конкретному сообщению
|
||
* @param {number|string} messageId - ID сообщения
|
||
* @param {Object} [options]
|
||
* @param {string} [options.block='center'] - Позиция в viewport
|
||
* @param {string} [options.behavior='auto'] - Тип анимации ('auto', 'smooth', 'instant')
|
||
*/
|
||
scrollToMessage(messageId, options = {}) {
|
||
const messageEl = this.scrollEl.querySelector(`[data-message-id="${messageId}"]`);
|
||
|
||
if (!messageEl) {
|
||
console.warn('[ScrollManagerV2] Message not found:', messageId);
|
||
return;
|
||
}
|
||
|
||
messageEl.scrollIntoView({
|
||
block: options.block || 'center',
|
||
behavior: options.behavior || 'auto',
|
||
inline: 'nearest'
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Проверяет, находится ли пользователь внизу
|
||
* @param {number} [threshold] - Порог в пикселях
|
||
* @returns {boolean}
|
||
*/
|
||
isNearBottom(threshold = null) {
|
||
const th = threshold ?? this.config.BOTTOM_THRESHOLD;
|
||
return (
|
||
this.scrollEl.scrollTop + this.scrollEl.clientHeight >=
|
||
this.scrollEl.scrollHeight - th
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Проверяет, находится ли пользователь вверху
|
||
* @returns {boolean}
|
||
*/
|
||
isAtTop() {
|
||
return this.scrollEl.scrollTop <= 10;
|
||
}
|
||
|
||
/**
|
||
* Получает текущий статус
|
||
* @returns {Object}
|
||
*/
|
||
getStatus() {
|
||
return {
|
||
isNearBottom: this.isNearBottom(),
|
||
isAtTop: this.isAtTop(),
|
||
isLoadingHistory: this._isLoadingHistory,
|
||
scrollPercentage: this._getScrollPercentage(),
|
||
firstVisibleMessageId: this._getFirstVisibleMessageId(),
|
||
lastVisibleMessageId: this._getLastVisibleMessageId()
|
||
};
|
||
}
|
||
|
||
// ==================== Приватные методы - Setup ====================
|
||
|
||
/**
|
||
* Настраивает IntersectionObserver для автозагрузки истории
|
||
* @private
|
||
*/
|
||
_setupIntersectionObserver() {
|
||
console.log('[ScrollManagerV2] 🔍 _setupIntersectionObserver called');
|
||
|
||
// Находим первое сообщение
|
||
const firstMessage = this.scrollEl.querySelector('.msg[data-message-id]');
|
||
|
||
if (!firstMessage) {
|
||
console.log('[ScrollManagerV2] ❌ No messages yet, observer not set');
|
||
return;
|
||
}
|
||
|
||
console.log('[ScrollManagerV2] First message found:', {
|
||
messageId: firstMessage.dataset.messageId,
|
||
offsetTop: firstMessage.offsetTop,
|
||
scrollTop: this.scrollEl.scrollTop,
|
||
firstMessageVisible: firstMessage.getBoundingClientRect().top < this.scrollEl.clientHeight
|
||
});
|
||
|
||
// Создаем observer
|
||
this._historyObserver = new IntersectionObserver(
|
||
(entries) => this._handleIntersection(entries),
|
||
{
|
||
root: this.scrollEl,
|
||
threshold: this.config.OBSERVER_THRESHOLD,
|
||
rootMargin: this.config.OBSERVER_ROOT_MARGIN
|
||
}
|
||
);
|
||
|
||
this._historyObserver.observe(firstMessage);
|
||
console.log('[ScrollManagerV2] ✅ Observer attached to first message');
|
||
}
|
||
|
||
/**
|
||
* Настраивает слушатель скролла
|
||
* @private
|
||
*/
|
||
_setupScrollListener() {
|
||
this._boundScrollHandler = throttle((e) => {
|
||
this._onScroll(e);
|
||
}, 100);
|
||
|
||
this.scrollEl.addEventListener('scroll', this._boundScrollHandler, { passive: true });
|
||
}
|
||
|
||
/**
|
||
* Настраивает слушатель resize
|
||
* @private
|
||
*/
|
||
_setupResizeListener() {
|
||
this._boundResizeHandler = debounce(() => {
|
||
this._onResize();
|
||
}, 200);
|
||
|
||
window.addEventListener('resize', this._boundResizeHandler);
|
||
}
|
||
|
||
// ==================== Приватные методы - Handlers ====================
|
||
|
||
/**
|
||
* Обработчик IntersectionObserver
|
||
* @private
|
||
*/
|
||
_handleIntersection(entries) {
|
||
console.log('[ScrollManagerV2] 👁️ IntersectionObserver triggered:', {
|
||
entriesCount: entries.length,
|
||
scrollTop: this.scrollEl.scrollTop,
|
||
scrollHeight: this.scrollEl.scrollHeight,
|
||
isNearBottom: this.isNearBottom(),
|
||
isInitializing: this._isInitializing
|
||
});
|
||
|
||
entries.forEach(entry => {
|
||
console.log('[ScrollManagerV2] Entry:', {
|
||
target: entry.target.dataset?.messageId,
|
||
isIntersecting: entry.isIntersecting,
|
||
intersectionRatio: entry.intersectionRatio,
|
||
isLoadingHistory: this._isLoadingHistory,
|
||
isNearBottom: this.isNearBottom(),
|
||
isInitializing: this._isInitializing,
|
||
willLoadHistory: entry.isIntersecting && !this._isLoadingHistory && !this.isNearBottom() && !this._isInitializing
|
||
});
|
||
|
||
// ✅ ВАЖНО: Игнорируем события во время инициализации
|
||
if (this._isInitializing) {
|
||
console.log('[ScrollManagerV2] 🚫 Initializing, ignoring observer event');
|
||
return;
|
||
}
|
||
|
||
// ✅ НЕ загружаем историю если пользователь внизу
|
||
if (entry.isIntersecting && !this._isLoadingHistory && !this.isNearBottom()) {
|
||
console.log('[ScrollManagerV2] 🚀 Triggering debounced history load...');
|
||
this._debouncedLoadHistory();
|
||
} else if (entry.isIntersecting && this.isNearBottom()) {
|
||
console.log('[ScrollManagerV2] 🚫 User at bottom, skipping history load (false positive)');
|
||
} else if (entry.isIntersecting && this._isLoadingHistory) {
|
||
console.log('[ScrollManagerV2] ⏸️ Already loading history, skipping');
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Триггер загрузки истории
|
||
* @private
|
||
*/
|
||
_triggerLoadHistory() {
|
||
console.log('[ScrollManagerV2] 🎬 _triggerLoadHistory called (from debounced)');
|
||
this.loadMoreHistory();
|
||
}
|
||
|
||
/**
|
||
* Обработчик скролла
|
||
* @private
|
||
*/
|
||
_onScroll(e) {
|
||
// Здесь можно добавить логику:
|
||
// - Показать/скрыть кнопку "scroll to bottom"
|
||
// - Отслеживание видимых сообщений для mark read
|
||
// - Отправка событий
|
||
}
|
||
|
||
/**
|
||
* Обработчик resize
|
||
* @private
|
||
*/
|
||
_onResize() {
|
||
// Обновляем observer если нужно
|
||
this._updateObserverTarget();
|
||
}
|
||
|
||
/**
|
||
* Триггерит загрузку истории
|
||
* @private
|
||
*/
|
||
async _triggerLoadHistory() {
|
||
await this.loadMoreHistory();
|
||
}
|
||
|
||
// ==================== Приватные методы - Observer ====================
|
||
|
||
/**
|
||
* Обновляет target IntersectionObserver
|
||
* @private
|
||
*/
|
||
_updateObserverTarget() {
|
||
if (!this._historyObserver) return;
|
||
|
||
this._historyObserver.disconnect();
|
||
|
||
const firstMessage = this.scrollEl.querySelector('.msg[data-message-id]');
|
||
if (firstMessage) {
|
||
this._historyObserver.observe(firstMessage);
|
||
}
|
||
}
|
||
|
||
// ==================== Приватные методы - Scroll Position ====================
|
||
|
||
/**
|
||
* Сохраняет текущую позицию скролла
|
||
* @private
|
||
* @returns {Object}
|
||
*/
|
||
_saveScrollPosition() {
|
||
return {
|
||
scrollTop: this.scrollEl.scrollTop,
|
||
scrollHeight: this.scrollEl.scrollHeight,
|
||
clientHeight: this.scrollEl.clientHeight,
|
||
firstVisibleId: this._getFirstVisibleMessageId()
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Восстанавливает позицию скролла после добавления контента
|
||
* @private
|
||
* @param {Object} savedPosition
|
||
*/
|
||
_restoreScrollPosition(savedPosition) {
|
||
if (!savedPosition) return;
|
||
|
||
// Используем двойной RAF для гарантии после layout
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
const heightDelta = this.scrollEl.scrollHeight - savedPosition.scrollHeight;
|
||
this.scrollEl.scrollTop = savedPosition.scrollTop + heightDelta;
|
||
});
|
||
});
|
||
}
|
||
|
||
// ==================== Приватные методы - Utilities ====================
|
||
|
||
/**
|
||
* Валидирует опции конструктора
|
||
* @private
|
||
*/
|
||
_validateOptions(options) {
|
||
if (!options.scrollElement) {
|
||
throw new Error('[ScrollManagerV2] scrollElement is required');
|
||
}
|
||
if (!options.loader) {
|
||
throw new Error('[ScrollManagerV2] loader is required');
|
||
}
|
||
if (!options.renderer) {
|
||
throw new Error('[ScrollManagerV2] renderer is required');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ждет стабилизации layout
|
||
* @private
|
||
*/
|
||
_waitForLayout() {
|
||
return new Promise(resolve => {
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
resolve();
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Получает процент прокрутки
|
||
* @private
|
||
*/
|
||
_getScrollPercentage() {
|
||
const scrollable = this.scrollEl.scrollHeight - this.scrollEl.clientHeight;
|
||
if (scrollable <= 0) return 100;
|
||
return Math.round((this.scrollEl.scrollTop / scrollable) * 100);
|
||
}
|
||
|
||
/**
|
||
* Получает ID первого видимого сообщения
|
||
* @private
|
||
*/
|
||
_getFirstVisibleMessageId() {
|
||
const containerRect = this.scrollEl.getBoundingClientRect();
|
||
const messages = this.scrollEl.querySelectorAll('.msg[data-message-id]');
|
||
|
||
for (const msg of messages) {
|
||
const rect = msg.getBoundingClientRect();
|
||
if (rect.top >= containerRect.top && rect.top <= containerRect.bottom) {
|
||
return msg.dataset.messageId;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Получает ID последнего видимого сообщения
|
||
* @private
|
||
*/
|
||
_getLastVisibleMessageId() {
|
||
const containerRect = this.scrollEl.getBoundingClientRect();
|
||
const messages = Array.from(this.scrollEl.querySelectorAll('.msg[data-message-id]'));
|
||
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
const rect = messages[i].getBoundingClientRect();
|
||
if (rect.bottom >= containerRect.top && rect.bottom <= containerRect.bottom) {
|
||
return messages[i].dataset.messageId;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export default ScrollManagerV2;
|