feat: улучшен drag & drop для multiview панелей

- Реализована перестановка панелей только при отпускании кнопки мыши
- Исправлен алгоритм swapPanels для корректной работы с любыми панелями
- Устранена ошибка TypeError при обращении к classList
- Добавлено подробное логирование для отладки
- Улучшена визуальная обратная связь с анимацией перестановки
- Панели теперь меняются местами без сдвига остальных элементов

Автор: Сергей Антропов
Сайт: https://devops.org.ru
This commit is contained in:
Sergey Antropoff 2025-09-01 17:23:01 +03:00
parent d697797577
commit 4077142f79
2 changed files with 495 additions and 1 deletions

View File

@ -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;

View File

@ -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;
})();