From 4077142f7961b06e1d705dbd020b431ec680d664 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Mon, 1 Sep 2025 17:23:01 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=20drag=20&=20drop=20=D0=B4=D0=BB=D1=8F=20multiview=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Реализована перестановка панелей только при отпускании кнопки мыши - Исправлен алгоритм swapPanels для корректной работы с любыми панелями - Устранена ошибка TypeError при обращении к classList - Добавлено подробное логирование для отладки - Улучшена визуальная обратная связь с анимацией перестановки - Панели теперь меняются местами без сдвига остальных элементов Автор: Сергей Антропов Сайт: https://devops.org.ru --- app/static/css/index.css | 84 +++++++- app/static/js/index.js | 412 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 495 insertions(+), 1 deletion(-) diff --git a/app/static/css/index.css b/app/static/css/index.css index dabb3f9..18859a6 100644 --- a/app/static/css/index.css +++ b/app/static/css/index.css @@ -610,6 +610,86 @@ a{color:var(--link)} overflow: hidden; padding: 2px; height: 100%; + /* Drag & Drop стили */ + cursor: move; + transition: all 0.2s ease; + position: relative; + user-select: none; +} + +/* Стили для перетаскиваемого элемента */ +.multi-view-panel.dragging { + opacity: 0.7; + transform: scale(1.02); + z-index: 1000; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + border-color: var(--accent); +} + +/* Стили для области drop - готовность к перестановке */ +.multi-view-panel.drop-target { + border-color: var(--ok); + background: var(--tab-active); + box-shadow: 0 0 0 2px var(--ok), 0 0 15px rgba(158, 206, 106, 0.2); + transform: scale(1.01); + transition: all 0.2s ease; +} + +/* Стили для области drop - перестановка в процессе */ +.multi-view-panel.drop-target.swapping { + box-shadow: 0 0 0 3px var(--ok), 0 0 25px rgba(158, 206, 106, 0.4); + transform: scale(1.02); + animation: dropTargetSwap 0.3s ease-in-out; +} + + + +/* Анимация перестановки */ +@keyframes dropTargetSwap { + 0% { + box-shadow: 0 0 0 3px var(--ok), 0 0 25px rgba(158, 206, 106, 0.4); + transform: scale(1.02); + } + 50% { + box-shadow: 0 0 0 4px var(--ok), 0 0 35px rgba(158, 206, 106, 0.6); + transform: scale(1.03); + } + 100% { + box-shadow: 0 0 0 3px var(--ok), 0 0 25px rgba(158, 206, 106, 0.4); + transform: scale(1.02); + } +} + +/* Стили для заголовка панели - область для захвата */ +.multi-view-header { + cursor: grab; + position: relative; +} + +.multi-view-header:active { + cursor: grabbing; +} + +/* Индикатор возможности перетаскивания */ +.multi-view-header::before { + content: "⋮⋮"; + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + color: var(--muted); + font-size: 12px; + opacity: 0.5; + transition: opacity 0.2s ease; +} + +.multi-view-panel:hover .multi-view-header::before { + opacity: 1; +} + +/* Скрываем индикатор при перетаскивании */ +.multi-view-panel.dragging .multi-view-header::before { + opacity: 0; } .multi-view-content { @@ -2003,6 +2083,8 @@ a{color:var(--link)} /* Равная высота строк для нескольких рядов (3+ окон) */ grid-auto-rows: 1fr; align-items: stretch; + /* Поддержка drag & drop */ + position: relative; } .multi-view-panel { @@ -2016,7 +2098,7 @@ a{color:var(--link)} } .multi-view-header { - padding: 5px 16px; + padding: 5px 16px 5px 24px; /* Увеличиваем левый отступ для индикатора перетаскивания */ background: var(--chip); border-bottom: 1px solid var(--border); display: flex; diff --git a/app/static/js/index.js b/app/static/js/index.js index 20545db..5a2b3a0 100644 --- a/app/static/js/index.js +++ b/app/static/js/index.js @@ -1867,6 +1867,14 @@ async function setupMultiView() { console.log(`setupMultiView: Multi-view setup completed for ${state.selectedContainers.length} containers`); + // Применяем сохраненный порядок панелей + setTimeout(() => { + // Сначала очищаем дубликаты, если они есть + cleanupDuplicatePanels(); + // Затем применяем порядок + applyPanelOrder(); + }, 100); // Небольшая задержка для завершения создания панелей + // Обновляем счетчики для multi view setTimeout(() => { recalculateMultiViewCounters(); @@ -1937,6 +1945,9 @@ function createMultiViewPanel(service) { console.error(`Failed to create multi-view log element for ${service.name}`); } + // Добавляем drag & drop функциональность + setupDragAndDrop(panel); + // Инициализируем состояние кнопок уровней логирования для этого контейнера setTimeout(() => { const levelBtns = panel.querySelectorAll('.level-btn'); @@ -5850,4 +5861,405 @@ function reinitializeElements() { return result; }; + // ============================================================================ + // DRAG & DROP ФУНКЦИОНАЛЬНОСТЬ ДЛЯ MULTI-VIEW ПАНЕЛЕЙ + // ============================================================================ + + /** + * Находит целевую панель для drop с расширенной зоной поиска + * @param {number} x - X координата курсора + * @param {number} y - Y координата курсора + * @param {HTMLElement} draggedElement - Перетаскиваемый элемент + * @returns {HTMLElement|null} Найденная панель или null + */ + function findTargetPanel(x, y, draggedElement) { + // Сначала пробуем найти панель точно под курсором + let elementBelow = document.elementFromPoint(x, y); + let targetPanel = elementBelow?.closest('.multi-view-panel'); + + if (targetPanel && targetPanel !== draggedElement) { + const targetId = targetPanel.getAttribute('data-container-id'); + console.log(`Found target panel: ${targetId} at (${x}, ${y})`); + return targetPanel; + } + + // Если не нашли, расширяем зону поиска в радиусе 50px + const searchRadius = 50; + const searchPoints = [ + { x: x - searchRadius, y: y }, + { x: x + searchRadius, y: y }, + { x: x, y: y - searchRadius }, + { x: x, y: y + searchRadius }, + { x: x - searchRadius/2, y: y - searchRadius/2 }, + { x: x + searchRadius/2, y: y - searchRadius/2 }, + { x: x - searchRadius/2, y: y + searchRadius/2 }, + { x: x + searchRadius/2, y: y + searchRadius/2 } + ]; + + for (const point of searchPoints) { + elementBelow = document.elementFromPoint(point.x, point.y); + targetPanel = elementBelow?.closest('.multi-view-panel'); + + if (targetPanel && targetPanel !== draggedElement) { + return targetPanel; + } + } + + // Если все еще не нашли, проверяем все панели на пересечение с курсором + const allPanels = document.querySelectorAll('.multi-view-panel'); + for (const panel of allPanels) { + if (panel === draggedElement) continue; + + const rect = panel.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + return panel; + } + } + + return null; + } + + /** + * Настраивает drag & drop функциональность для панели multi-view + * @param {HTMLElement} panel - Панель для настройки drag & drop + */ + function setupDragAndDrop(panel) { + const header = panel.querySelector('.multi-view-header'); + if (!header) return; + + let isDragging = false; + let dragStartX = 0; + let dragStartY = 0; + let draggedElement = null; + let draggedIndex = -1; + let dropTarget = null; + + // Обработчик начала перетаскивания + header.addEventListener('mousedown', (e) => { + // Проверяем, что клик по заголовку, а не по кнопкам уровней + if (e.target.closest('.level-btn')) return; + + isDragging = true; + draggedElement = panel; + draggedIndex = Array.from(panel.parentNode.children).indexOf(panel); + + dragStartX = e.clientX; + dragStartY = e.clientY; + + // Добавляем класс для визуального эффекта + panel.classList.add('dragging'); + + // Предотвращаем выделение текста + e.preventDefault(); + + console.log(`Drag started for panel: ${panel.getAttribute('data-container-id')}`); + }); + + // Обработчик движения мыши + document.addEventListener('mousemove', (e) => { + if (!isDragging || !draggedElement) return; + + const deltaX = e.clientX - dragStartX; + const deltaY = e.clientY - dragStartY; + + // Минимальное расстояние для начала перетаскивания + if (Math.abs(deltaX) < 5 && Math.abs(deltaY) < 5) return; + + // Обновляем позицию элемента + draggedElement.style.transform = `translate(${deltaX}px, ${deltaY}px)`; + + // Находим элемент под курсором с расширенной зоной поиска + const targetPanel = findTargetPanel(e.clientX, e.clientY, draggedElement); + + // Убираем подсветку с предыдущей цели + if (dropTarget && dropTarget !== targetPanel) { + dropTarget.classList.remove('drop-target'); + } + + // Подсвечиваем новую цель (без перестановки во время перетаскивания) + if (targetPanel && targetPanel !== draggedElement) { + // Убираем подсветку с предыдущей цели + if (dropTarget && dropTarget !== targetPanel) { + dropTarget.classList.remove('drop-target'); + } + + dropTarget = targetPanel; + targetPanel.classList.add('drop-target'); + } else { + if (dropTarget) { + dropTarget.classList.remove('drop-target'); + } + dropTarget = null; + } + }); + + // Обработчик окончания перетаскивания + document.addEventListener('mouseup', (e) => { + if (!isDragging || !draggedElement) return; + + isDragging = false; + + // Убираем визуальные эффекты + draggedElement.classList.remove('dragging'); + draggedElement.style.transform = ''; + + // Если есть целевая панель, выполняем перестановку + if (dropTarget) { + const containerId = draggedElement.getAttribute('data-container-id'); + const targetContainerId = dropTarget.getAttribute('data-container-id'); + + console.log(`Dropping panel ${containerId} at position of ${targetContainerId}`); + + // Сохраняем ссылку на элемент для setTimeout + const targetElement = dropTarget; + + // Добавляем класс для анимации перестановки + targetElement.classList.add('swapping'); + + // Выполняем перестановку + swapPanels(draggedElement, targetElement); + + // Убираем класс анимации через короткое время + setTimeout(() => { + if (targetElement && targetElement.classList) { + targetElement.classList.remove('swapping'); + } + }, 300); + + // Убираем подсветку + targetElement.classList.remove('drop-target'); + + // Сохраняем новый порядок в localStorage + savePanelOrder(); + } + + // Сбрасываем переменные + draggedElement = null; + draggedIndex = -1; + dropTarget = null; + }); + } + + /** + * Мгновенно меняет местами две панели в DOM и обновляет массив selectedContainers + * @param {HTMLElement} panel1 - Первая панель + * @param {HTMLElement} panel2 - Вторая панель + */ + function swapPanels(panel1, panel2) { + if (!panel1 || !panel2 || panel1 === panel2) return; + + const containerId1 = panel1.getAttribute('data-container-id'); + const containerId2 = panel2.getAttribute('data-container-id'); + + if (!containerId1 || !containerId2) { + console.error('Missing container IDs:', { containerId1, containerId2 }); + return; + } + + console.log(`Before swap - Panel1: ${containerId1}, Panel2: ${containerId2}`); + console.log('Before swap - selectedContainers:', [...state.selectedContainers]); + + // Меняем местами панели в DOM + const parent = panel1.parentNode; + + // Получаем позиции панелей в DOM + const panel1NextSibling = panel1.nextSibling; + const panel2NextSibling = panel2.nextSibling; + + // Вставляем panel1 на место panel2 + parent.insertBefore(panel1, panel2NextSibling); + + // Вставляем panel2 на место panel1 + parent.insertBefore(panel2, panel1NextSibling); + + // Обновляем массив selectedContainers + const index1 = state.selectedContainers.indexOf(containerId1); + const index2 = state.selectedContainers.indexOf(containerId2); + + if (index1 !== -1 && index2 !== -1) { + // Меняем местами элементы в массиве + state.selectedContainers[index1] = containerId2; + state.selectedContainers[index2] = containerId1; + + console.log(`After swap - Panel1: ${containerId1}, Panel2: ${containerId2}`); + console.log('After swap - selectedContainers:', [...state.selectedContainers]); + + // Проверяем, что атрибуты data-container-id остались правильными + const newContainerId1 = panel1.getAttribute('data-container-id'); + const newContainerId2 = panel2.getAttribute('data-container-id'); + console.log(`After swap - Panel1 data-container-id: ${newContainerId1}, Panel2 data-container-id: ${newContainerId2}`); + } else { + console.error('Could not find container IDs in selectedContainers:', { index1, index2, containerId1, containerId2 }); + } + } + + /** + * Переставляет контейнеры в массиве selectedContainers + * @param {string} draggedId - ID перетаскиваемого контейнера + * @param {string} targetId - ID целевого контейнера + */ + function reorderContainers(draggedId, targetId) { + const draggedIndex = state.selectedContainers.indexOf(draggedId); + const targetIndex = state.selectedContainers.indexOf(targetId); + + if (draggedIndex === -1 || targetIndex === -1) return; + + // Удаляем перетаскиваемый элемент + state.selectedContainers.splice(draggedIndex, 1); + + // Вставляем его в новую позицию + const newTargetIndex = draggedIndex < targetIndex ? targetIndex - 1 : targetIndex; + state.selectedContainers.splice(newTargetIndex, 0, draggedId); + + console.log('Reordered containers:', state.selectedContainers); + } + + /** + * Сохраняет порядок панелей в localStorage + */ + function savePanelOrder() { + const panelOrder = Array.from(document.querySelectorAll('.multi-view-panel')) + .map(panel => panel.getAttribute('data-container-id')) + .filter(id => id); + + // Удаляем дубликаты, сохраняя порядок первого вхождения + const uniquePanelOrder = [...new Set(panelOrder)]; + + localStorage.setItem('lb_panel_order', JSON.stringify(uniquePanelOrder)); + console.log('Panel order saved:', uniquePanelOrder); + } + + /** + * Загружает порядок панелей из localStorage + * @returns {Array} Массив ID контейнеров в сохраненном порядке + */ + function loadPanelOrder() { + try { + const savedOrder = localStorage.getItem('lb_panel_order'); + if (savedOrder) { + const order = JSON.parse(savedOrder); + // Удаляем дубликаты из загруженного порядка + const uniqueOrder = [...new Set(order)]; + console.log('Panel order loaded:', uniqueOrder); + return uniqueOrder; + } + } catch (error) { + console.error('Error loading panel order:', error); + } + return null; + } + + /** + * Применяет сохраненный порядок панелей + */ + function applyPanelOrder() { + if (!state.multiViewMode) return; + + const savedOrder = loadPanelOrder(); + if (!savedOrder || savedOrder.length === 0) return; + + const grid = document.getElementById('multiViewGrid'); + if (!grid) return; + + // Создаем карту панелей по ID контейнера + const panels = Array.from(grid.children); + const panelMap = {}; + panels.forEach(panel => { + const containerId = panel.getAttribute('data-container-id'); + if (containerId) { + panelMap[containerId] = panel; + } + }); + + // Переставляем панели согласно сохраненному порядку + savedOrder.forEach(containerId => { + const panel = panelMap[containerId]; + if (panel && panel.parentNode === grid) { + grid.appendChild(panel); + } + }); + + // Добавляем панели для новых контейнеров, которых нет в сохраненном порядке + const currentContainers = [...state.selectedContainers]; + const newContainers = currentContainers.filter(id => !savedOrder.includes(id)); + + newContainers.forEach(containerId => { + // Проверяем, что панель для этого контейнера еще не существует + const existingPanel = grid.querySelector(`[data-container-id="${containerId}"]`); + if (!existingPanel) { + const service = state.services.find(s => s.id === containerId); + if (service) { + console.log(`Creating new panel for container: ${service.name} (${containerId})`); + const panel = createMultiViewPanel(service); + grid.appendChild(panel); + } + } else { + console.log(`Panel for container ${containerId} already exists, skipping creation`); + } + }); + + // Обновляем массив selectedContainers, сохраняя все текущие контейнеры + // но применяя сохраненный порядок для тех, которые есть в сохраненном порядке + const orderedContainers = savedOrder.filter(id => currentContainers.includes(id)); + state.selectedContainers = [...orderedContainers, ...newContainers]; + + // Обновляем grid template columns для нового количества панелей + const totalPanels = state.selectedContainers.length; + let columns = 1; + if (totalPanels === 1) columns = 1; + else if (totalPanels === 2) columns = 2; + else if (totalPanels <= 4) columns = 2; + else if (totalPanels <= 6) columns = 3; + else columns = 4; + + grid.style.gridTemplateColumns = `repeat(${columns}, 1fr)`; + console.log(`Updated grid template columns to: repeat(${columns}, 1fr) for ${totalPanels} panels`); + + console.log('Applied panel order:', state.selectedContainers); + } + + /** + * Очищает дубликаты из localStorage и обновляет порядок панелей + */ + function cleanupDuplicatePanels() { + try { + const savedOrder = localStorage.getItem('lb_panel_order'); + if (savedOrder) { + const order = JSON.parse(savedOrder); + const uniqueOrder = [...new Set(order)]; + + // Если были найдены дубликаты, обновляем localStorage + if (order.length !== uniqueOrder.length) { + console.log('Found duplicates in panel order, cleaning up:', order, '->', uniqueOrder); + localStorage.setItem('lb_panel_order', JSON.stringify(uniqueOrder)); + + // Если мы в multiview режиме, пересоздаем панели + if (state.multiViewMode) { + console.log('Recreating panels to remove duplicates'); + // Очищаем текущий grid + const grid = document.getElementById('multiViewGrid'); + if (grid) { + grid.innerHTML = ''; + } + + // Пересоздаем панели в правильном порядке + setTimeout(() => { + applyPanelOrder(); + }, 100); + } + } + } + } catch (error) { + console.error('Error cleaning up duplicate panels:', error); + } + } + + // Экспортируем функции для использования в других частях кода + window.setupDragAndDrop = setupDragAndDrop; + window.savePanelOrder = savePanelOrder; + window.loadPanelOrder = loadPanelOrder; + window.applyPanelOrder = applyPanelOrder; + window.cleanupDuplicatePanels = cleanupDuplicatePanels; + window.swapPanels = swapPanels; + })();