tree-moved / index.html
SimpleCodeTM's picture
Add 3 files
262c881 verified
raw
history blame
32.7 kB
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Интерактивное дерево с плавным перетаскиванием</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
.tree-node {
transition: all 0.2s ease;
position: relative;
}
.tree-node:hover {
background-color: #f3f4f6;
}
.tree-node.dragging {
opacity: 0.5;
background-color: #e5e7eb;
}
.tree-node-placeholder {
border: 2px dashed #3b82f6;
background-color: #eff6ff;
height: 40px;
margin: 4px 0;
border-radius: 4px;
transition: all 0.1s ease;
}
.nested {
display: none;
transition: all 0.3s ease;
}
.nested.active {
display: block;
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.drag-handle {
cursor: move;
opacity: 0.5;
transition: opacity 0.2s ease;
}
.drag-handle:hover {
opacity: 1;
}
.drag-ghost {
position: absolute;
z-index: 1000;
opacity: 0.8;
pointer-events: none;
background: white;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border-radius: 0.375rem;
border: 1px solid #e5e7eb;
transform: translate(0, 0);
transition: transform 0.1s ease;
}
</style>
</head>
<body class="bg-gray-50 font-sans p-6">
<div class="max-w-4xl mx-auto">
<div class="bg-white rounded-xl shadow-md overflow-hidden">
<div class="p-6">
<h1 class="text-2xl font-bold text-gray-800 mb-6">Интерактивное дерево с плавным перетаскиванием</h1>
<div class="flex space-x-4 mb-6">
<button id="addRootNode" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2">
<i class="fas fa-plus"></i>
<span>Добавить корневой элемент</span>
</button>
<button id="expandAll" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg flex items-center space-x-2">
<i class="fas fa-expand"></i>
<span>Развернуть все</span>
</button>
<button id="collapseAll" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg flex items-center space-x-2">
<i class="fas fa-compress"></i>
<span>Свернуть все</span>
</button>
</div>
<div id="treeContainer" class="border border-gray-200 rounded-lg p-4 min-h-40">
<!-- Дерево будет сгенерировано здесь -->
<div id="treeRoot" class="space-y-1"></div>
</div>
</div>
</div>
</div>
<!-- Модальное окно для добавления/редактирования узла -->
<div id="nodeModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-lg p-6 max-w-md w-full">
<div class="flex justify-between items-start mb-4">
<h3 class="text-lg font-medium text-gray-900" id="modalTitle">Добавить узел</h3>
<button id="closeNodeModal" class="text-gray-400 hover:text-gray-500">
<i class="fas fa-times"></i>
</button>
</div>
<div class="mb-4">
<label for="nodeName" class="block text-sm font-medium text-gray-700 mb-1">Название узла</label>
<input type="text" id="nodeName" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="flex justify-end space-x-3">
<button id="cancelNode" class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50">
Отмена
</button>
<button id="saveNode" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Сохранить
</button>
</div>
</div>
</div>
<script>
// Инициализация дерева
let treeData = [
{
id: 1,
name: "Корневой элемент 1",
children: [
{
id: 2,
name: "Дочерний элемент 1.1",
children: [
{
id: 3,
name: "Вложенный элемент 1.1.1",
children: []
},
{
id: 4,
name: "Вложенный элемент 1.1.2",
children: []
}
]
},
{
id: 5,
name: "Дочерний элемент 1.2",
children: []
}
]
},
{
id: 6,
name: "Корневой элемент 2",
children: []
}
];
// Переменные для управления состоянием
let nextId = 7;
let currentNodeId = null;
let currentParentId = null;
let isEditing = false;
let draggedNode = null;
let draggedNodeParent = null;
let draggedNodeIndex = null;
let dragGhost = null;
let lastDragPosition = { x: 0, y: 0 };
let placeholder = null;
let isDragging = false;
let targetParentId = null;
let targetIndex = null;
// DOM элементы
const treeRoot = document.getElementById('treeRoot');
const nodeModal = document.getElementById('nodeModal');
const nodeNameInput = document.getElementById('nodeName');
const modalTitle = document.getElementById('modalTitle');
const addRootNodeBtn = document.getElementById('addRootNode');
const expandAllBtn = document.getElementById('expandAll');
const collapseAllBtn = document.getElementById('collapseAll');
// Инициализация дерева при загрузке
document.addEventListener('DOMContentLoaded', () => {
renderTree();
setupEventListeners();
});
// Рендер дерева
function renderTree(data = treeData, parentElement = treeRoot, level = 0) {
parentElement.innerHTML = '';
if (data.length === 0 && parentElement === treeRoot) {
parentElement.innerHTML = '<p class="text-gray-500 italic">Дерево пустое</p>';
return;
}
data.forEach((node, index) => {
const hasChildren = node.children && node.children.length > 0;
const nodeElement = document.createElement('div');
nodeElement.className = 'tree-node pl-' + (level * 4);
nodeElement.dataset.id = node.id;
nodeElement.dataset.parentId = parentElement.dataset.id || 'root';
nodeElement.dataset.index = index;
nodeElement.innerHTML = `
<div class="flex items-center py-2 px-3 rounded-lg border border-gray-200">
<div class="flex items-center flex-1">
<span class="drag-handle mr-2 text-gray-400 cursor-move">
<i class="fas fa-grip-vertical"></i>
</span>
${hasChildren ? `
<button class="toggle-node mr-2 text-gray-500 hover:text-gray-700" data-id="${node.id}">
<i class="fas fa-caret-right"></i>
</button>
` : '<span class="w-6"></span>'}
<span class="node-name">${node.name}</span>
</div>
<div class="flex space-x-2">
<button class="edit-node p-1 text-blue-500 hover:text-blue-700" data-id="${node.id}">
<i class="fas fa-edit"></i>
</button>
<button class="add-child p-1 text-green-500 hover:text-green-700" data-id="${node.id}">
<i class="fas fa-plus"></i>
</button>
<button class="delete-node p-1 text-red-500 hover:text-red-700" data-id="${node.id}">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
${hasChildren ? `
<div class="nested pl-4" data-parent-id="${node.id}"></div>
` : ''}
`;
parentElement.appendChild(nodeElement);
// Рендерим детей, если они есть
if (hasChildren) {
const nestedContainer = nodeElement.querySelector('.nested');
renderTree(node.children, nestedContainer, level + 1);
}
});
}
// Настройка обработчиков событий
function setupEventListeners() {
// Кнопки модального окна
document.getElementById('closeNodeModal').addEventListener('click', closeNodeModal);
document.getElementById('cancelNode').addEventListener('click', closeNodeModal);
document.getElementById('saveNode').addEventListener('click', saveNode);
// Кнопки управления деревом
addRootNodeBtn.addEventListener('click', () => {
openNodeModal(null, null, false);
});
expandAllBtn.addEventListener('click', expandAllNodes);
collapseAllBtn.addEventListener('click', collapseAllNodes);
// Делегирование событий для динамически созданных элементов
treeRoot.addEventListener('click', (e) => {
const target = e.target.closest('.toggle-node');
if (target) {
toggleNode(target.dataset.id);
return;
}
const editBtn = e.target.closest('.edit-node');
if (editBtn) {
openNodeModal(editBtn.dataset.id, null, true);
return;
}
const addChildBtn = e.target.closest('.add-child');
if (addChildBtn) {
openNodeModal(null, addChildBtn.dataset.id, false);
return;
}
const deleteBtn = e.target.closest('.delete-node');
if (deleteBtn) {
deleteNode(deleteBtn.dataset.id);
return;
}
});
// Перетаскивание
setupDragAndDrop();
}
// Настройка плавного перетаскивания
function setupDragAndDrop() {
document.addEventListener('mousedown', (e) => {
const handle = e.target.closest('.drag-handle');
if (!handle) return;
const nodeElement = handle.closest('.tree-node');
if (!nodeElement) return;
draggedNode = findNodeById(treeData, nodeElement.dataset.id);
draggedNodeParent = nodeElement.dataset.parentId === 'root' ? treeData :
findNodeById(treeData, nodeElement.dataset.parentId).children;
draggedNodeIndex = parseInt(nodeElement.dataset.index);
// Создаем призрачный элемент для перетаскивания
dragGhost = nodeElement.cloneNode(true);
dragGhost.classList.add('drag-ghost');
dragGhost.style.width = nodeElement.offsetWidth + 'px';
dragGhost.style.left = nodeElement.getBoundingClientRect().left + 'px';
dragGhost.style.top = nodeElement.getBoundingClientRect().top + 'px';
document.body.appendChild(dragGhost);
// Сохраняем начальную позицию
lastDragPosition = { x: e.clientX, y: e.clientY };
isDragging = true;
// Добавляем класс для визуального эффекта
nodeElement.classList.add('dragging');
// Отключаем выделение текста при перетаскивании
document.body.style.userSelect = 'none';
// Обработчики для плавного перетаскивания
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
e.preventDefault();
});
function handleDragMove(e) {
if (!isDragging || !dragGhost) return;
// Вычисляем смещение
const dx = e.clientX - lastDragPosition.x;
const dy = e.clientY - lastDragPosition.y;
// Обновляем позицию призрака
const ghostRect = dragGhost.getBoundingClientRect();
dragGhost.style.left = (ghostRect.left + dx) + 'px';
dragGhost.style.top = (ghostRect.top + dy) + 'px';
// Обновляем последнюю позицию
lastDragPosition = { x: e.clientX, y: e.clientY };
// Определяем элемент под курсором
const elements = document.elementsFromPoint(e.clientX, e.clientY);
let targetElement = null;
for (const el of elements) {
if (el.classList.contains('tree-node') || el.classList.contains('nested') || el.id === 'treeRoot') {
targetElement = el;
break;
}
}
// Обработка позиционирования плейсхолдера
if (targetElement) {
// Удаляем старый плейсхолдер
removePlaceholder();
if (targetElement.classList.contains('tree-node')) {
const rect = targetElement.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
// Определяем, куда вставлять - выше или ниже узла
if (e.clientY < midpoint) {
showPlaceholder(targetElement, 'before');
targetParentId = targetElement.dataset.parentId;
targetIndex = parseInt(targetElement.dataset.index);
} else {
const hasChildren = targetElement.querySelector('.toggle-node') !== null;
if (hasChildren) {
const nested = targetElement.querySelector('.nested');
if (!nested.classList.contains('active')) {
// Если узел свернут, показываем плейсхолдер внутри
showPlaceholder(nested, 'first');
targetParentId = targetElement.dataset.id;
targetIndex = 0;
} else {
// Если узел развернут, показываем плейсхолдер после
showPlaceholder(targetElement, 'after');
targetParentId = targetElement.dataset.parentId;
targetIndex = parseInt(targetElement.dataset.index) + 1;
}
} else {
showPlaceholder(targetElement, 'after');
targetParentId = targetElement.dataset.parentId;
targetIndex = parseInt(targetElement.dataset.index) + 1;
}
}
}
else if (targetElement.classList.contains('nested')) {
const children = Array.from(targetElement.children).filter(el => el.classList.contains('tree-node'));
if (children.length === 0) {
showPlaceholder(targetElement, 'first');
targetParentId = targetElement.dataset.parentId;
targetIndex = 0;
} else {
const lastChild = children[children.length - 1];
showPlaceholder(lastChild, 'after');
targetParentId = targetElement.dataset.parentId;
targetIndex = children.length;
}
}
else if (targetElement.id === 'treeRoot') {
const children = Array.from(targetElement.children).filter(el => el.classList.contains('tree-node'));
if (children.length === 0) {
showPlaceholder(targetElement, 'first');
targetParentId = 'root';
targetIndex = 0;
} else {
const lastChild = children[children.length - 1];
showPlaceholder(lastChild, 'after');
targetParentId = 'root';
targetIndex = children.length;
}
}
}
}
function handleDragEnd(e) {
if (!isDragging) return;
// Удаляем призрака
if (dragGhost) {
dragGhost.remove();
dragGhost = null;
}
// Удаляем плейсхолдер
removePlaceholder();
// Восстанавливаем выделение текста
document.body.style.userSelect = '';
// Удаляем обработчики
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
// Если есть перетаскиваемый узел и целевая позиция
if (draggedNode && targetParentId !== null && targetIndex !== null) {
// Определяем новый родительский массив
let newParentArray;
if (targetParentId === 'root') {
newParentArray = treeData;
} else {
const parentNode = findNodeById(treeData, targetParentId);
if (parentNode) {
newParentArray = parentNode.children;
} else {
newParentArray = treeData;
}
}
// Проверяем, что перемещение допустимо (не в самого себя или своих потомков)
if (!isDescendant(draggedNode, targetParentId === 'root' ? null : findNodeById(treeData, targetParentId))) {
// Удаляем узел из старого места
const oldIndex = draggedNodeParent.indexOf(draggedNode);
if (oldIndex !== -1) {
draggedNodeParent.splice(oldIndex, 1);
}
// Вставляем узел в новое место
if (targetIndex > newParentArray.length) {
targetIndex = newParentArray.length;
}
newParentArray.splice(targetIndex, 0, draggedNode);
// Перерисовываем дерево
renderTree();
// Автоматически раскрываем родительский узел, если нужно
if (targetParentId !== 'root') {
const parentElement = document.querySelector(`.tree-node[data-id="${targetParentId}"]`);
if (parentElement) {
const nested = parentElement.querySelector('.nested');
if (nested && !nested.classList.contains('active')) {
toggleNode(targetParentId);
}
}
}
}
}
// Сбрасываем состояние перетаскивания
resetDragState();
// Удаляем класс dragging со всех элементов
document.querySelectorAll('.tree-node.dragging').forEach(el => {
el.classList.remove('dragging');
});
isDragging = false;
targetParentId = null;
targetIndex = null;
}
}
// Показать плейсхолдер для перетаскивания
function showPlaceholder(element, position) {
removePlaceholder();
placeholder = document.createElement('div');
placeholder.className = 'tree-node-placeholder';
placeholder.dataset.position = position;
if (position === 'before') {
element.parentNode.insertBefore(placeholder, element);
}
else if (position === 'after') {
element.parentNode.insertBefore(placeholder, element.nextSibling);
}
else if (position === 'first') {
element.insertBefore(placeholder, element.firstChild);
}
else if (position === 'last') {
element.appendChild(placeholder);
}
}
// Удалить плейсхолдер
function removePlaceholder() {
if (placeholder) {
placeholder.remove();
placeholder = null;
}
}
// Сбросить состояние перетаскивания
function resetDragState() {
draggedNode = null;
draggedNodeParent = null;
draggedNodeIndex = null;
}
// Проверить, является ли узел потомком другого узла
function isDescendant(node, parentNode) {
if (!parentNode) return false;
if (node.id === parentNode.id) return true;
// Проверяем всех детей родительского узла
for (const child of parentNode.children) {
if (isDescendant(node, child)) {
return true;
}
}
return false;
}
// Найти узел по ID
function findNodeById(data, id) {
for (let i = 0; i < data.length; i++) {
if (data[i].id == id) {
return data[i];
}
if (data[i].children && data[i].children.length > 0) {
const found = findNodeById(data[i].children, id);
if (found) return found;
}
}
return null;
}
// Найти родительский узел
function findParentNode(data, id, parent = null) {
for (let i = 0; i < data.length; i++) {
if (data[i].id == id) {
return parent;
}
if (data[i].children && data[i].children.length > 0) {
const found = findParentNode(data[i].children, id, data[i]);
if (found) return found;
}
}
return null;
}
// Открыть модальное окно для добавления/редактирования узла
function openNodeModal(nodeId, parentId, editMode) {
currentNodeId = nodeId;
currentParentId = parentId;
isEditing = editMode;
if (editMode) {
modalTitle.textContent = 'Редактировать узел';
const node = findNodeById(treeData, nodeId);
nodeNameInput.value = node.name;
} else {
modalTitle.textContent = parentId ? 'Добавить дочерний узел' : 'Добавить корневой узел';
nodeNameInput.value = '';
}
nodeModal.classList.remove('hidden');
nodeNameInput.focus();
}
// Закрыть модальное окно
function closeNodeModal() {
nodeModal.classList.add('hidden');
}
// Сохранить узел
function saveNode() {
const name = nodeNameInput.value.trim();
if (!name) {
alert('Пожалуйста, введите название узла');
return;
}
if (isEditing) {
// Редактирование существующего узла
const node = findNodeById(treeData, currentNodeId);
if (node) {
node.name = name;
}
} else {
// Добавление нового узла
const newNode = {
id: nextId++,
name: name,
children: []
};
if (currentParentId) {
// Добавляем как дочерний узел
const parentNode = findNodeById(treeData, currentParentId);
if (parentNode) {
parentNode.children.push(newNode);
}
} else {
// Добавляем как корневой узел
treeData.push(newNode);
}
}
// Перерисовываем дерево
renderTree();
closeNodeModal();
}
// Удалить узел
function deleteNode(nodeId) {
if (!confirm('Вы уверены, что хотите удалить этот узел и все его дочерние элементы?')) {
return;
}
const parent = findParentNode(treeData, nodeId);
if (parent) {
// Удаляем из дочерних элементов родителя
parent.children = parent.children.filter(node => node.id != nodeId);
} else {
// Удаляем из корневых элементов
treeData = treeData.filter(node => node.id != nodeId);
}
// Перерисовываем дерево
renderTree();
}
// Переключить видимость дочерних узлов
function toggleNode(nodeId) {
const nodeElement = document.querySelector(`.tree-node[data-id="${nodeId}"]`);
if (!nodeElement) return;
const nestedContainer = nodeElement.querySelector('.nested');
if (!nestedContainer) return;
const toggleBtn = nodeElement.querySelector('.toggle-node');
if (nestedContainer.classList.contains('active')) {
// Скрываем
nestedContainer.classList.remove('active');
toggleBtn.innerHTML = '<i class="fas fa-caret-right"></i>';
} else {
// Показываем
nestedContainer.classList.add('active');
toggleBtn.innerHTML = '<i class="fas fa-caret-down"></i>';
}
}
// Развернуть все узлы
function expandAllNodes() {
document.querySelectorAll('.nested').forEach(nested => {
nested.classList.add('active');
});
document.querySelectorAll('.toggle-node').forEach(btn => {
btn.innerHTML = '<i class="fas fa-caret-down"></i>';
});
}
// Свернуть все узлы
function collapseAllNodes() {
document.querySelectorAll('.nested').forEach(nested => {
nested.classList.remove('active');
});
document.querySelectorAll('.toggle-node').forEach(btn => {
btn.innerHTML = '<i class="fas fa-caret-right"></i>';
});
}
</script>
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=SimpleCodeTM/tree-moved" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body>
</html>