// backend/static/js/components/messageContextMenu.js /** * Контекстное меню для сообщений (как в Telegram/WhatsApp) * - Мобилка: долгое нажатие * - Десктоп: правый клик */ export class MessageContextMenu { constructor(config = {}) { console.log('[MessageContextMenu] Constructor called', config); this.onReactionSelect = config.onReactionSelect || (() => {}); this.emojis = config.emojis || ['👍', '❤️', '😂', '😮', '�']; // 5 смайликов + кнопка = 6 элементов this.currentUserId = config.currentUserId; this.containerId = config.containerId || 'chatScroll'; this.activeMenu = null; this.highlightedMessage = null; // Храним ссылку на подсвеченное сообщение this.longPressTimer = null; this.longPressDuration = 500; // 500ms для долгого нажатия this.init(); this.attachToExistingMessages(); this.observeNewMessages(); } init() { // Закрытие меню при клике вне его document.addEventListener('click', (e) => { if (this.activeMenu && !e.target.closest('.message-context-menu') && !e.target.closest('.quick-reactions-panel')) { this.removeHighlight(); // Убираем подсветку this.closeMenu(); } }, true); // Используем capture phase для приоритета // Закрытие при Escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.activeMenu) { this.removeHighlight(); // Убираем подсветку this.closeMenu(); } }); // Закрытие при скролле document.addEventListener('scroll', () => { if (this.activeMenu) { this.removeHighlight(); // Убираем подсветку this.closeMenu(); } }, true); } /** * Подключить контекстное меню ко всем существующим сообщениям */ attachToExistingMessages() { console.log('[MessageContextMenu] Attaching to existing messages in:', this.containerId); const container = document.getElementById(this.containerId); if (!container) { console.warn('[MessageContextMenu] Container not found:', this.containerId); return; } const messages = container.querySelectorAll('.msg[data-message-id]'); console.log('[MessageContextMenu] Found', messages.length, 'messages'); messages.forEach((msg) => { if (!msg.dataset.contextMenuAttached) { this.attachToMessage(msg); msg.dataset.contextMenuAttached = 'true'; } }); } /** * Наблюдать за новыми сообщениями через MutationObserver */ observeNewMessages() { const container = document.getElementById(this.containerId); if (!container) { console.warn('[MessageContextMenu] Container not found for observer:', this.containerId); return; } // РЕФАКТОРИНГ: MutationObserver УДАЛЕН // Он дублировал обработку сообщений с событием chat:message-added // Теперь используется ТОЛЬКО событийная модель - chat-detail-enhanced.js // слушает chat:message-added и вызывает attachToMessage console.log('[MessageContextMenu] Observer setup skipped - using event-based model only'); } /** * Подключить обработчики к сообщению */ attachToMessage(messageElement) { const messageId = messageElement.dataset.messageId; const authorId = parseInt(messageElement.dataset.authorId); const isOwn = authorId === this.currentUserId; console.log('[MessageContextMenu] attachToMessage called:', { messageId, authorId, currentUserId: this.currentUserId, isOwn, alreadyAttached: messageElement.dataset.contextMenuAttached }); // Десктоп: правый клик messageElement.addEventListener('contextmenu', (e) => { // Игнорируем клик на кнопках реакций и контейнере реакций if (e.target.closest('.reaction-button') || e.target.closest('.message-reactions')) { console.log('[MessageContextMenu] Ignoring contextmenu on reaction button/container'); return; // Не вызываем preventDefault, позволяем событию идти дальше } console.log('[MessageContextMenu] *** CONTEXTMENU EVENT FIRED ***'); console.log('[MessageContextMenu] Event details:', { clientX: e.clientX, clientY: e.clientY, target: e.target.tagName, messageId }); e.preventDefault(); e.stopPropagation(); // Предотвращаем всплытие к обработчику на document this.showMenu(messageElement, messageId, isOwn, e.clientX, e.clientY); }); console.log('[MessageContextMenu] Contextmenu listener attached to message:', messageId); // Мобилка: долгое нажатие let touchStartX, touchStartY; messageElement.addEventListener('touchstart', (e) => { // Игнорируем долгое нажатие на кнопках реакций и контейнере реакций if (e.target.closest('.reaction-button') || e.target.closest('.message-reactions')) { console.log('[MessageContextMenu] Ignoring touchstart on reaction button/container'); return; } console.log('[MessageContextMenu] Touchstart event on message:', messageId); touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; this.longPressTimer = setTimeout(() => { console.log('[MessageContextMenu] Long press triggered on message:', messageId); // Вибрация для тактильной обратной связи if (navigator.vibrate) { navigator.vibrate(50); } this.showMenu(messageElement, messageId, isOwn, touchStartX, touchStartY); }, this.longPressDuration); }, { passive: true }); messageElement.addEventListener('touchmove', (e) => { // Отменяем долгое нажатие если палец двигается const touch = e.touches[0]; const moveX = Math.abs(touch.clientX - touchStartX); const moveY = Math.abs(touch.clientY - touchStartY); if (moveX > 10 || moveY > 10) { console.log('[MessageContextMenu] Touch moved, cancelling long press'); clearTimeout(this.longPressTimer); } }, { passive: true }); messageElement.addEventListener('touchend', () => { console.log('[MessageContextMenu] Touch ended'); clearTimeout(this.longPressTimer); }, { passive: true }); messageElement.addEventListener('touchcancel', () => { console.log('[MessageContextMenu] Touch cancelled'); clearTimeout(this.longPressTimer); }, { passive: true }); console.log('[MessageContextMenu] All event listeners attached to message:', messageId); } /** * Показать контекстное меню */ showMenu(messageElement, messageId, isOwn, x, y) { console.log('[MessageContextMenu] showMenu called:', { messageId, isOwn, x, y, messageElement: messageElement?.tagName }); // Убрать подсветку с предыдущего сообщения (если было) this.removeHighlight(); // Закрыть предыдущее меню this.closeMenu(); // Создать меню console.log('[MessageContextMenu] Creating menu...'); const menu = this.createMenu(messageElement, messageId, isOwn); console.log('[MessageContextMenu] Menu created, appending to body'); document.body.appendChild(menu); // Добавляем панель реакций отдельно if (menu.reactionsPanel) { document.body.appendChild(menu.reactionsPanel); console.log('[MessageContextMenu] Reactions panel appended to body'); } this.activeMenu = menu; console.log('[MessageContextMenu] Menu appended to DOM'); // Добавить подсветку НОВОГО сообщения console.log('[MessageContextMenu] Adding highlight to message'); messageElement.classList.add('message-highlighted'); this.highlightedMessage = messageElement; // Сохраняем ссылку // Позиционировать меню и реакции console.log('[MessageContextMenu] Positioning menu'); this.positionMenu(menu, x, y); // Анимация появления requestAnimationFrame(() => { console.log('[MessageContextMenu] Adding show class for animation'); menu.classList.add('show'); if (menu.reactionsPanel) { menu.reactionsPanel.classList.add('show'); } }); } /** * Создать HTML меню */ createMenu(messageElement, messageId, isOwn) { console.log('[MessageContextMenu] createMenu:', { messageId, isOwn }); const menu = document.createElement('div'); menu.className = 'message-context-menu'; menu.dataset.messageId = messageId; console.log('[MessageContextMenu] Menu element created with class:', menu.className); // Секция быстрых реакций (Telegram-style) console.log('[MessageContextMenu] Creating reactions HTML with', this.emojis.length, 'emojis'); const reactionsHtml = this.emojis.map(emoji => ` `).join('') + ` `; // Секция действий const actionsHtml = ` `; console.log('[MessageContextMenu] Setting innerHTML...'); menu.innerHTML = actionsHtml; console.log('[MessageContextMenu] innerHTML set successfully'); // Создаём отдельный floating элемент для быстрых реакций (как в Telegram) const reactionsPanel = document.createElement('div'); reactionsPanel.className = 'quick-reactions-panel'; reactionsPanel.innerHTML = `
${reactionsHtml}
`; menu.reactionsPanel = reactionsPanel; // Сохраняем ссылку console.log('[MessageContextMenu] Reactions panel created'); // Обработчик кликов на панели реакций reactionsPanel.addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) { console.log('[MessageContextMenu] Click outside action button on reactions panel'); return; } e.preventDefault(); e.stopPropagation(); const action = btn.dataset.action; const emoji = btn.dataset.emoji; console.log('[MessageContextMenu] Reaction button clicked:', action, emoji); // Если это кнопка "показать больше реакций" - раскрываем полный список if (action === 'show-more-reactions') { console.log('[MessageContextMenu] Show more reactions clicked - expanding'); this.expandReactionsList(reactionsPanel, messageElement, messageId); return; } this.handleAction(action, messageElement, messageId, emoji); this.removeHighlight(); this.closeMenu(); }); // Обработчики на основном меню console.log('[MessageContextMenu] Attaching click handler to menu'); menu.addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) { console.log('[MessageContextMenu] Click outside action button'); return; } e.preventDefault(); e.stopPropagation(); const action = btn.dataset.action; const emoji = btn.dataset.emoji; console.log('[MessageContextMenu] Action button clicked:', action, emoji); this.handleAction(action, messageElement, messageId, emoji); this.removeHighlight(); // Убираем подсветку при выборе действия this.closeMenu(); }); console.log('[MessageContextMenu] Menu fully constructed'); return menu; } /** * Позиционировать меню на экране */ positionMenu(menu, x, y) { console.log('[MessageContextMenu] positionMenu called:', { x, y }); const menuRect = menu.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const isMobile = viewportWidth <= 768; console.log('[MessageContextMenu] Menu dimensions:', { width: menuRect.width, height: menuRect.height, viewportWidth, viewportHeight, isMobile }); let left, top; // Единое позиционирование для всех устройств - рядом с курсором/касанием left = x; top = y; // Не выходим за правый край if (left + menuRect.width > viewportWidth) { left = viewportWidth - menuRect.width - 10; console.log('[MessageContextMenu] Adjusted left to prevent overflow:', left); } // Не выходим за левый край if (left < 10) { left = 10; console.log('[MessageContextMenu] Adjusted left to prevent underflow:', left); } // Не выходим за нижний край if (top + menuRect.height > viewportHeight) { top = y - menuRect.height; console.log('[MessageContextMenu] Adjusted top to prevent bottom overflow:', top); } // Не выходим за верхний край if (top < 10) { top = 10; console.log('[MessageContextMenu] Adjusted top to prevent top overflow:', top); } console.log('[MessageContextMenu] Final position:', { left, top }); menu.style.left = `${left}px`; menu.style.top = `${top}px`; // Позиционируем панель реакций над меню (одинаково для всех устройств) if (menu.reactionsPanel) { const reactionsRect = menu.reactionsPanel.getBoundingClientRect(); const reactionsLeft = left; const reactionsTop = top - reactionsRect.height - 12; // 12px gap menu.reactionsPanel.style.left = `${reactionsLeft}px`; menu.reactionsPanel.style.top = `${reactionsTop}px`; } } /** * Раскрыть список реакций (показать все доступные эмодзи) */ expandReactionsList(reactionsPanel, messageElement, messageId) { const moreBtn = reactionsPanel.querySelector('.quick-reaction-btn-more'); const reactionsList = reactionsPanel.querySelector('.quick-reactions-list'); // Сохраняем текущую позицию и высоту const currentTop = parseFloat(reactionsPanel.style.top); const currentHeight = reactionsPanel.offsetHeight; // Проверяем текущее состояние const isExpanded = moreBtn.classList.contains('expanded'); if (isExpanded) { // Сворачиваем - возвращаем только быстрые реакции moreBtn.classList.remove('expanded'); const quickEmojis = this.emojis.map(emoji => ` `).join('') + ` `; reactionsList.innerHTML = quickEmojis; // Пересчитываем позицию после сворачивания (двигаем вниз) requestAnimationFrame(() => { const newHeight = reactionsPanel.offsetHeight; const heightDiff = currentHeight - newHeight; reactionsPanel.style.top = `${currentTop + heightDiff}px`; }); } else { // Раскрываем - показываем все эмодзи moreBtn.classList.add('expanded'); const allEmojis = [ '👍', '👎', '❤️', '🔥', '😂', '😮', '😢', '😡', '👏', '🙏', '💯', '🎉', '❓', '💪', '🤝', '👀', '🤔', '😊', '😎', '🤗', '😴', '🥳', '😇', '🤩' ]; const expandedEmojis = allEmojis.map(emoji => ` `).join('') + ` `; reactionsList.innerHTML = expandedEmojis; // Пересчитываем позицию после раскрытия (двигаем вверх) requestAnimationFrame(() => { const newHeight = reactionsPanel.offsetHeight; const heightDiff = newHeight - currentHeight; reactionsPanel.style.top = `${currentTop - heightDiff}px`; }); } } /** * Обработать действие */ handleAction(action, messageElement, messageId, emoji) { console.log('[ContextMenu] Action:', action, 'Message:', messageId); switch (action) { case 'react': this.handleReact(messageId, emoji); break; case 'reply': this.handleReply(messageElement); break; case 'edit': this.handleEdit(messageElement); break; case 'delete': this.handleDelete(messageId); break; case 'forward': this.handleForward(messageId); break; case 'copy': this.handleCopy(messageElement); break; case 'select': this.handleSelect(messageElement, messageId); break; } } /** * Добавить реакцию */ async handleReact(messageId, emoji) { console.log('[ContextMenu] Adding reaction:', emoji, 'to message:', messageId); try { await this.onReactionSelect(messageId, emoji); } catch (error) { console.error('[ContextMenu] Failed to add reaction:', error); this.showToast('Не удалось добавить реакцию'); } } /** * Ответить на сообщение */ handleReply(messageElement) { console.log('[MessageContextMenu] handleReply called'); const bubble = messageElement.querySelector('.bubble'); if (!bubble) { console.warn('[MessageContextMenu] Bubble not found in message element'); return; } // Получаем данные сообщения const messageText = bubble.textContent.trim(); const authorName = messageElement.querySelector('.small.text-secondary')?.textContent.split('·')[0]?.trim() || 'Пользователь'; const messageId = messageElement.dataset.messageId; console.log('[MessageContextMenu] Reply data:', { messageId, authorName, messageTextLength: messageText.length }); // Используем chatFormManager если доступен if (window.chatFormManager) { console.log('[MessageContextMenu] Using chatFormManager.setModeToReply'); window.chatFormManager.setModeToReply(messageId, authorName, messageText); } else { console.warn('[MessageContextMenu] chatFormManager not available, using fallback'); // Фоллбэк на старую логику (на случай если модуль не загружен) const textarea = document.querySelector('#id_content'); if (textarea) { textarea.focus(); this.showToast(`Ответ на сообщение от ${authorName}`); } else { console.error('[MessageContextMenu] Textarea #id_content not found'); } } } /** * Редактировать сообщение */ async handleEdit(messageElement) { console.log('[MessageContextMenu] handleEdit called'); const messageId = messageElement.dataset.messageId; console.log('[MessageContextMenu] Message ID:', messageId); // Получаем полные данные сообщения let messageData = null; // Приоритет 1: MessageStore (если сообщение уже загружено) if (window.messageStore && typeof window.messageStore.getMessage === 'function') { try { messageData = window.messageStore.getMessage(messageId); if (messageData) { console.log('[MessageContextMenu] ✓ Message from Store:', { id: messageData.id, content: messageData.content?.substring(0, 50), attachmentsCount: messageData.attachments?.length || 0 }); } } catch (e) { console.error('[MessageContextMenu] Store error:', e); } } // Приоритет 2: ChatController V2 if (!messageData && window.chatController && typeof window.chatController.getMessage === 'function') { try { messageData = window.chatController.getMessage(messageId); if (messageData) { console.log('[MessageContextMenu] ✓ Message from ChatController'); } } catch (e) { console.error('[MessageContextMenu] ChatController error:', e); } } // Fallback: Извлекаем из DOM (теряется Markdown и нет файлов) if (!messageData) { console.warn('[MessageContextMenu] Message not found in Store, using DOM fallback'); const bubble = messageElement.querySelector('.bubble'); const messageContent = messageElement.querySelector('.message-content'); console.log('[MessageContextMenu] DOM elements found:', { hasBubble: !!bubble, hasMessageContent: !!messageContent, bubbleHTML: bubble?.innerHTML.substring(0, 200), messageContentHTML: messageContent?.innerHTML.substring(0, 200) }); if (!bubble && !messageContent) { console.error('[MessageContextMenu] Neither .bubble nor .message-content found!'); return; } // Извлекаем текст - приоритет .message-content, fallback на .bubble const sourceElement = messageContent || bubble; let messageText = ''; // Более аккуратное извлечение текста const extractText = (element) => { let text = ''; element.childNodes.forEach(node => { if (node.nodeType === Node.TEXT_NODE) { text += node.textContent; } else if (node.nodeName === 'BR') { text += '\n'; } else if (node.nodeType === Node.ELEMENT_NODE) { // Рекурсивно извлекаем текст из вложенных элементов // НО игнорируем системные элементы if (!node.classList.contains('edited-indicator') && !node.classList.contains('message-time') && !node.classList.contains('message-attachments') && !node.classList.contains('message-reactions')) { text += extractText(node); } } }); return text; }; messageText = extractText(sourceElement).trim(); console.log('[MessageContextMenu] Extracted text:', { length: messageText.length, preview: messageText.substring(0, 100) }); // Извлекаем вложения из DOM const attachments = []; const attachmentsContainer = messageElement.querySelector('.message-attachments'); console.log('[MessageContextMenu] DOM: Searching for attachments...', { hasContainer: !!attachmentsContainer, messageElement: messageElement }); if (attachmentsContainer) { // Ищем все типы вложений: .attachment-link (файлы) и .attachment-item (медиа) const attachmentElements = attachmentsContainer.querySelectorAll('.attachment-link, .attachment-item'); console.log('[MessageContextMenu] DOM: Found attachments:', attachmentElements.length); attachmentElements.forEach((element, index) => { let fileUrl, fileName, fileSize = ''; if (element.classList.contains('attachment-link')) { // Обычные файлы (документы, аудио) fileUrl = element.getAttribute('href'); fileName = element.querySelector('.attachment-name')?.textContent || `Файл ${index + 1}`; fileSize = element.querySelector('.attachment-size')?.textContent || ''; } else if (element.classList.contains('attachment-item')) { // Медиа файлы (изображения, видео) const linkElement = element.tagName === 'A' ? element : element.closest('a'); const imgElement = element.querySelector('img'); const videoElement = element.querySelector('video'); if (linkElement) { fileUrl = linkElement.getAttribute('href'); } else if (imgElement) { fileUrl = imgElement.getAttribute('src'); } else if (videoElement) { fileUrl = videoElement.getAttribute('src'); } if (imgElement) { fileName = imgElement.getAttribute('alt') || `Изображение ${index + 1}`; } else if (videoElement) { fileName = `Видео ${index + 1}`; } else { fileName = `Медиа ${index + 1}`; } } if (!fileUrl) { console.warn('[MessageContextMenu] Skipping attachment without URL:', element); return; } // Извлекаем ID из data-атрибута (приоритет) или из URL (fallback) let attachmentId = element.getAttribute('data-attachment-id'); if (!attachmentId) { // Fallback: пробуем извлечь из URL (может не сработать) console.warn('[MessageContextMenu] No data-attachment-id, trying URL extraction'); attachmentId = `url-${fileUrl.split('/').pop().split('.')[0]}-${index}`; } console.log('[MessageContextMenu] ID extraction:', { fileUrl, dataAttrId: element.getAttribute('data-attachment-id'), attachmentId }); const attachment = { id: attachmentId, filename: fileName, file_name: fileName, file_url: fileUrl, url: fileUrl, size_str: fileSize }; attachments.push(attachment); console.log('[MessageContextMenu] ✓ Extracted attachment from DOM:', { index, type: element.classList.contains('attachment-link') ? 'file' : 'media', fileName, fileUrl, attachmentId, fullObject: attachment }); }); } messageData = { id: messageId, content: messageText, attachments: attachments }; console.log('[MessageContextMenu] Extracted from DOM:', { textLength: messageText.length, attachmentsCount: attachments.length }); } console.log('[MessageContextMenu] Final message data:', { id: messageData.id, contentLength: messageData.content?.length || 0, hasAttachments: !!messageData.attachments?.length, attachmentsCount: messageData.attachments?.length || 0, attachments: messageData.attachments // Полный список для отладки }); // Используем chatFormManager если доступен if (window.chatFormManager && typeof window.chatFormManager.setModeToEdit === 'function') { console.log('[MessageContextMenu] Calling chatFormManager.setModeToEdit with:', { id: messageData.id, contentLength: messageData.content?.length || 0, attachmentsCount: messageData.attachments?.length || 0, attachments: messageData.attachments }); window.chatFormManager.setModeToEdit( messageData.id, messageData.content || '', messageData.attachments || [] ); } else { console.error('[MessageContextMenu] chatFormManager not available or setModeToEdit not a function'); console.log('[MessageContextMenu] window.chatFormManager:', window.chatFormManager); } } /** * Удалить сообщение */ async handleDelete(messageId) { console.log('[MessageContextMenu] handleDelete called:', messageId); if (!confirm('Удалить это сообщение?')) { console.log('[MessageContextMenu] Delete cancelled by user'); return; } try { const csrfToken = this.getCsrfToken(); const url = `/api/v1/communications/messages/${messageId}/`; console.log('[MessageContextMenu] Sending DELETE request to:', url); console.log('[MessageContextMenu] CSRF token:', csrfToken ? 'present' : 'missing'); const response = await fetch(url, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken } }); console.log('[MessageContextMenu] Response status:', response.status); console.log('[MessageContextMenu] Response ok:', response.ok); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } // Статус 204 = No Content, не парсим JSON if (response.status === 204) { console.log('[MessageContextMenu] Delete successful (204)'); console.log('[MessageContextMenu] NOTE: DOM will be updated via WebSocket'); // НЕ удаляем из DOM здесь - это сделает WebSocket handler // Так все пользователи увидят удаление в реальном времени this.showToast('Сообщение удалено'); console.log('[MessageContextMenu] Toast shown'); return; } const data = await response.json(); console.log('[MessageContextMenu] Response data:', data); if (data.ok) { console.log('[MessageContextMenu] Delete successful'); console.log('[MessageContextMenu] NOTE: DOM will be updated via WebSocket'); // НЕ удаляем из DOM здесь - это сделает WebSocket handler // Так все пользователи увидят удаление в реальном времени this.showToast('Сообщение удалено'); console.log('[MessageContextMenu] Toast shown'); } else { throw new Error(data.error || 'Failed to delete message'); } } catch (error) { console.error('[MessageContextMenu] Failed to delete message:', error); this.showToast('Не удалось удалить сообщение'); } } /** * Переслать сообщение */ handleForward(messageId) { console.log('[ContextMenu] Forward message:', messageId); // Отправляем событие для активации режима пересылки одного сообщения window.dispatchEvent(new CustomEvent('message-forward-single', { detail: { messageId } })); this.showToast('Выберите чат для пересылки'); } /** * Копировать текст сообщения */ async handleCopy(messageElement) { console.log('[ContextMenu] Copy message text'); const bubble = messageElement.querySelector('.bubble'); if (!bubble) { console.warn('[ContextMenu] Bubble not found'); return; } // Получаем только содержимое сообщения (без автора, времени, реакций) const messageContent = bubble.querySelector('.message-content'); if (!messageContent) { console.warn('[ContextMenu] Message content not found'); return; } // Копируем только чистый текст сообщения const text = messageContent.textContent .replace(/\(ред\.\)/, '') // Убираем метку редактирования .trim(); try { await navigator.clipboard.writeText(text); this.showToast('Текст скопирован'); } catch (err) { console.error('[ContextMenu] Failed to copy text:', err); this.showToast('Не удалось скопировать'); } } /** * Выбрать сообщение */ handleSelect(messageElement, messageId) { console.log('[ContextMenu] Select message:', messageId); // Отправляем событие для активации режима выделения window.dispatchEvent(new CustomEvent('message-context-menu', { detail: { action: 'select', messageId: messageId, messageElement: messageElement } })); } /** * Получить CSRF токен */ getCsrfToken() { const cookie = document.cookie .split('; ') .find(row => row.startsWith('csrftoken=')); return cookie ? cookie.split('=')[1] : ''; } /** * Экранировать HTML */ escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Показать временное уведомление */ showToast(message) { console.log('[MessageContextMenu] showToast:', message); const toast = document.createElement('div'); toast.className = 'context-menu-toast'; toast.textContent = message; console.log('[MessageContextMenu] Toast created, appending to body'); document.body.appendChild(toast); requestAnimationFrame(() => { console.log('[MessageContextMenu] Adding show class to toast'); toast.classList.add('show'); }); setTimeout(() => { console.log('[MessageContextMenu] Removing show class from toast'); toast.classList.remove('show'); setTimeout(() => { console.log('[MessageContextMenu] Removing toast from DOM'); toast.remove(); }, 300); }, 2000); } /** * Убрать подсветку сообщения */ removeHighlight() { if (this.highlightedMessage) { console.log('[MessageContextMenu] Removing highlight from message:', this.highlightedMessage.dataset.messageId); this.highlightedMessage.classList.remove('message-highlighted'); this.highlightedMessage = null; console.log('[MessageContextMenu] Highlight removed'); } else { console.log('[MessageContextMenu] No highlighted message to remove'); } } /** * Закрыть меню */ closeMenu() { if (!this.activeMenu) { console.log('[MessageContextMenu] closeMenu: no active menu'); return; } console.log('[MessageContextMenu] Closing menu'); // Сохраняем ссылку на закрываемое меню и панель реакций const menuToClose = this.activeMenu; const reactionsToClose = menuToClose.reactionsPanel; // Сразу обнуляем activeMenu (важно для быстрых кликов!) this.activeMenu = null; // Анимация закрытия console.log('[MessageContextMenu] Removing show class for fade out'); menuToClose.classList.remove('show'); if (reactionsToClose) { reactionsToClose.classList.remove('show'); } setTimeout(() => { console.log('[MessageContextMenu] Removing menu from DOM'); menuToClose.remove(); if (reactionsToClose) { reactionsToClose.remove(); } }, 200); } } export default MessageContextMenu;