拖拽排序列表(HTML+JS)

上图!!!

HTML源码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>拖拽排序列表</title>
  <link rel="stylesheet" href="static/css/拖拽排序列表.css">
</head>
<body>

<div class="todo-container">
  <!-- 头部 -->
  <div class="todo-header">
    <h1>📋 TODO 清单</h1>
    <p>拖拽手柄可重新排序 · 支持触屏</p>
  </div>

  <!-- 输入区域 -->
  <div class="todo-input-area">
    <input type="text" id="todoInput" placeholder="添加新任务..." maxlength="60">
    <button id="btnAdd">添加</button>
  </div>

  <!-- 列表区域 -->
  <ul class="todo-list" id="todoList">
    <!-- JS 动态渲染 -->
  </ul>

  <!-- 底部工具栏 -->
  <div class="todo-footer">
    <span id="todoCount">共 0 项</span>
    <button id="btnClearCompleted">清除已完成</button>
  </div>
</div>

<script>
// ==================== 拖拽排序列表 ====================

// ---------- 默认数据 ----------
const defaultTodos = [
  { id: 1, text: '学习 JavaScript 事件机制',    completed: false },
  { id: 2, text: '完成拖拽排序功能开发',          completed: false },
  { id: 3, text: '复习 CSS transform 和过渡动画', completed: true  },
  { id: 4, text: '练习解构赋值与箭头函数',        completed: false },
  { id: 5, text: '阅读 Promise 相关文档',         completed: false },
];

// ---------- localStorage ----------
const STORAGE_KEY = 'drag_sort_todos';
let todos = JSON.parse(localStorage.getItem(STORAGE_KEY)) || defaultTodos;

// ---------- DOM 引用 ----------
const todoList   = document.getElementById('todoList');
const todoInput  = document.getElementById('todoInput');
const btnAdd     = document.getElementById('btnAdd');
const todoCount  = document.getElementById('todoCount');
const btnClear   = document.getElementById('btnClearCompleted');

// ---------- 拖拽状态 ----------
let dragState = {
  active:    false,       // 是否正在拖拽
  startY:    0,           // 按下时的鼠标 Y
  startX:    0,           // 按下时的鼠标 X
  item:      null,        // 被拖拽的 DOM 元素
  index:     -1,          // 被拖拽项的原始索引
  offsetY:   0,           // 鼠标相对于元素顶部的偏移
  clone:     null,        // 拖拽时的浮动克隆元素
};

// ==================== 工具函数 ====================
const saveToStorage = () => {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
};

const nextId = () => {
  if (todos.length === 0) return 1;
  return Math.max(...todos.map(t => t.id)) + 1;
};

// ==================== 渲染 ====================
const render = () => {
  if (todos.length === 0) {
    todoList.innerHTML = '<li class="todo-empty">✨ 暂无任务,添加一个吧</li>';
  } else {
    todoList.innerHTML = todos
      .map(({ id, text, completed }) => `
        <li class="todo-item${completed ? ' completed' : ''}" data-id="${id}">
          <span class="drag-handle" title="拖拽排序">三</span>
          <input type="checkbox" class="todo-check" ${completed ? 'checked' : ''}>
          <span class="todo-text">${text}</span>
          <button class="todo-delete" title="删除">✕</button>
        </li>
      `)
      .join('');
  }
  todoCount.textContent = `共 ${todos.length} 项`;
  saveToStorage();
};

// ==================== 列表事件委托 ====================
todoList.addEventListener('click', (e) => {
  const target = e.target;
  const li = target.closest('.todo-item');
  if (!li) return;
  const id = +li.dataset.id;

  // --- 删除按钮 ---
  if (target.classList.contains('todo-delete')) {
    todos = todos.filter(t => t.id !== id);
    render();
  }

  // --- 复选框 ---
  if (target.classList.contains('todo-check')) {
    const item = todos.find(t => t.id === id);
    if (item) {
      item.completed = target.checked;
      // 同步 class
      target.checked
        ? li.classList.add('completed')
        : li.classList.remove('completed');
      saveToStorage();
    }
  }
});

// ==================== 添加任务 ====================
const addTodo = () => {
  const text = todoInput.value.trim();
  if (!text) return;
  todos.push({ id: nextId(), text, completed: false });
  todoInput.value = '';
  render();
};

btnAdd.addEventListener('click', addTodo);
todoInput.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') addTodo();
});

// ==================== 清除已完成 ====================
btnClear.addEventListener('click', () => {
  todos = todos.filter(t => !t.completed);
  render();
});

// ==================== 拖拽核心逻辑 ====================

/**
 * 获取触摸或鼠标事件的坐标
 */
const getEventPos = (e) => {
  // 触屏事件
  if (e.touches && e.touches.length > 0) {
    return { x: e.touches[0].clientX, y: e.touches[0].clientY };
  }
  if (e.changedTouches && e.changedTouches.length > 0) {
    return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
  }
  // 鼠标事件
  return { x: e.clientX, y: e.clientY };
};

/**
 * 找到离当前 Y 坐标最近的列表项索引(用于计算插入位置)
 */
const getClosestIndex = (y) => {
  const items = [...todoList.querySelectorAll('.todo-item:not(.dragging)')];
  let closest = -1;
  let minDist = Infinity;

  items.forEach((item, idx) => {
    const { top, bottom } = item.getBoundingClientRect();
    const mid = (top + bottom) / 2;
    const dist = Math.abs(y - mid);
    if (dist < minDist) {
      minDist = dist;
      // 在原始数组中还原真实索引:跳过被拖拽项
      const realIdx = idx >= dragState.index ? idx + 1 : idx;
      closest = realIdx;
    }
  });

  return closest;
};

/**
 * 开始拖拽 --- mousedown / touchstart
 */
const onDragStart = (e) => {
  // 只响应拖拽手柄
  const handle = e.target.closest('.drag-handle');
  if (!handle) return;

  const li = handle.closest('.todo-item');
  if (!li) return;

  e.preventDefault();

  const { x, y } = getEventPos(e);
  const { top, left, width } = li.getBoundingClientRect();

  // 解构赋值记录状态
  dragState = {
    active:  true,
    startY:  y,
    startX:  x,
    item:    li,
    index:   [...todoList.children].indexOf(li),
    offsetY: y - top,
    offsetX: x - left,
    clone:   null,
  };

  // 视觉反馈:被拖拽项半透明 + 放大阴影
  li.classList.add('dragging');

  // 创建浮动克隆元素(跟随鼠标)
  const clone = li.cloneNode(true);
  clone.classList.add('dragging');
  clone.style.position       = 'fixed';
  clone.style.top            = `${top}px`;
  clone.style.left           = `${left}px`;
  clone.style.width          = `${width}px`;
  clone.style.pointerEvents  = 'none';
  clone.style.zIndex         = '999';
  clone.style.margin         = '0';
  document.body.appendChild(clone);

  dragState.clone = clone;
};

/**
 * 移动中 --- mousemove / touchmove
 */
const onDragMove = (e) => {
  if (!dragState.active) return;
  e.preventDefault();

  const { y } = getEventPos(e);
  const { clone, offsetY } = dragState;

  // 克隆元素跟随鼠标移动(transform 实现流畅跟随)
  if (clone) {
    clone.style.transform = `translateY(${y - dragState.startY}px)`;
  }

  // 计算最近插入位置,视觉上让其他项让位
  const targetIndex = getClosestIndex(y);

  // 清除之前的占位样式
  todoList.querySelectorAll('.todo-item.drag-placeholder').forEach(el => {
    el.classList.remove('drag-placeholder');
  });

  // 给目标位置项加占位样式
  if (targetIndex >= 0 && targetIndex < todos.length && targetIndex !== dragState.index) {
    const items = todoList.querySelectorAll('.todo-item');
    // 在未拖拽的项中找目标
    const visibleItems = [...items].filter(el => !el.classList.contains('dragging'));
    let placeholderIdx = targetIndex;
    if (targetIndex > dragState.index) placeholderIdx = targetIndex - 1;
    if (placeholderIdx >= 0 && placeholderIdx < visibleItems.length) {
      visibleItems[placeholderIdx].classList.add('drag-placeholder');
    }
  }
};

/**
 * 放下 --- mouseup / touchend
 */
const onDragEnd = (e) => {
  if (!dragState.active) return;
  e.preventDefault();

  const { item, index: fromIdx, clone } = dragState;
  const { y } = getEventPos(e);
  const targetIdx = getClosestIndex(y);

  // 移除克隆元素
  if (clone) {
    clone.remove();
  }

  // 清除所有样式
  item.classList.remove('dragging');
  todoList.querySelectorAll('.todo-item.drag-placeholder').forEach(el => {
    el.classList.remove('drag-placeholder');
  });

  // 如果有有效目标且与原始位置不同 → 重排数组 + 重新渲染
  if (targetIdx >= 0 && targetIdx !== fromIdx && targetIdx < todos.length) {
    const [moved] = todos.splice(fromIdx, 1);
    todos.splice(targetIdx, 0, moved);
    render();
  }

  // 重置拖拽状态
  dragState = {
    active: false,
    startY: 0,
    startX: 0,
    item: null,
    index: -1,
    offsetY: 0,
    clone: null,
  };
};

// ==================== 绑定事件 ====================

// ------ 鼠标事件 ------
todoList.addEventListener('mousedown', onDragStart);
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup',   onDragEnd);

// ------ 触屏事件(进阶要求) ------
todoList.addEventListener('touchstart', onDragStart, { passive: false });
document.addEventListener('touchmove',  onDragMove,  { passive: false });
document.addEventListener('touchend',   onDragEnd);

// 防止移动端拖拽时页面滚动
document.addEventListener('touchmove', (e) => {
  if (dragState.active) e.preventDefault();
}, { passive: false });

// ==================== 初始化 ====================
render();
</script>

</body>
</html>

CSS源码

css 复制代码
/* ==================== 拖拽排序列表样式 ==================== */

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  padding: 40px 20px;
}

/* ---------- 主容器 ---------- */
.todo-container {
  width: 100%;
  max-width: 520px;
  background: #fff;
  border-radius: 16px;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  overflow: hidden;
}

/* ---------- 头部 ---------- */
.todo-header {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: #fff;
  padding: 24px 28px;
  text-align: center;
}

.todo-header h1 {
  font-size: 26px;
  font-weight: 700;
  letter-spacing: 1px;
}

.todo-header p {
  font-size: 13px;
  opacity: 0.85;
  margin-top: 4px;
}

/* ---------- 输入区域 ---------- */
.todo-input-area {
  display: flex;
  gap: 10px;
  padding: 20px 24px;
  border-bottom: 1px solid #eee;
}

.todo-input-area input {
  flex: 1;
  padding: 10px 16px;
  border: 2px solid #e0e0e0;
  border-radius: 10px;
  font-size: 15px;
  outline: none;
  transition: border-color 0.25s, box-shadow 0.25s;
}

.todo-input-area input:focus {
  border-color: #667eea;
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}

.todo-input-area button {
  padding: 10px 22px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: #fff;
  border: none;
  border-radius: 10px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.2s, transform 0.15s;
}

.todo-input-area button:hover {
  opacity: 0.9;
}

.todo-input-area button:active {
  transform: scale(0.96);
}

/* ---------- 列表 ---------- */
.todo-list {
  list-style: none;
  padding: 8px 0;
  min-height: 60px;
  position: relative;
}

/* ---------- 空状态 ---------- */
.todo-empty {
  text-align: center;
  padding: 48px 20px;
  color: #bbb;
  font-size: 15px;
}

/* ---------- 单项 ---------- */
.todo-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 20px;
  background: #fff;
  border-bottom: 1px solid #f0f0f0;
  transition: background 0.2s, transform 0.15s ease;
  position: relative;
  user-select: none;
}

.todo-item:last-child {
  border-bottom: none;
}

.todo-item:hover {
  background: #fafbff;
}

/* ---------- 拖拽手柄 ---------- */
.drag-handle {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 4px 2px;
  cursor: grab;
  color: #bbb;
  font-size: 12px;
  letter-spacing: 2px;
  line-height: 1;
  transition: color 0.2s, transform 0.1s;
  flex-shrink: 0;
}

.drag-handle:hover {
  color: #667eea;
}

.drag-handle:active {
  cursor: grabbing;
}

/* ---------- 复选框 ---------- */
.todo-item input[type="checkbox"] {
  width: 20px;
  height: 20px;
  cursor: pointer;
  accent-color: #667eea;
  flex-shrink: 0;
}

/* ---------- 文本 ---------- */
.todo-text {
  flex: 1;
  font-size: 15px;
  color: #333;
  transition: color 0.2s, text-decoration 0.2s;
  word-break: break-word;
}

.todo-item.completed .todo-text {
  text-decoration: line-through;
  color: #bbb;
}

/* ---------- 删除按钮 ---------- */
.todo-delete {
  background: none;
  border: none;
  color: #ccc;
  font-size: 18px;
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 6px;
  transition: color 0.2s, background 0.2s;
  flex-shrink: 0;
}

.todo-delete:hover {
  color: #e74c3c;
  background: #fff0f0;
}

/* ==================== 拖拽状态样式 ==================== */

/* 被拖拽的项 --- 半透明 + 放大阴影 */
.todo-item.dragging {
  opacity: 0.5;
  background: #f0f4ff;
  box-shadow: 0 12px 32px rgba(102, 126, 234, 0.35),
              0 4px 8px rgba(0, 0, 0, 0.1);
  transform: scale(1.03);
  z-index: 100;
  border-radius: 10px;
  border-bottom: none;
}

/* 拖拽中的手柄样式 */
.todo-item.dragging .drag-handle {
  cursor: grabbing;
  color: #667eea;
}

/* 占位项(其他项给拖拽项让位时的占位) */
.todo-item.drag-placeholder {
  opacity: 0.3;
  background: #f0f4ff;
  border: 2px dashed #667eea;
  border-radius: 10px;
}

/* ---------- 底部工具栏 ---------- */
.todo-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 14px 24px;
  border-top: 1px solid #eee;
  font-size: 13px;
  color: #999;
}

.todo-footer button {
  background: none;
  border: 1px solid #e0e0e0;
  color: #888;
  padding: 6px 14px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 13px;
  transition: all 0.2s;
}

.todo-footer button:hover {
  background: #f5f5f5;
  color: #667eea;
  border-color: #667eea;
}
相关推荐
Data1 小时前
把 React 前端做成「插件市场」:前后端联动的行业包可插拔架构
前端
光影少年1 小时前
RN Bridge 原理
前端·react native·react.js
Larcher1 小时前
别再搞混了!一文讲透 AI Workflow 和 Agent 的本质区别
javascript·人工智能·后端
daols881 小时前
vue vxe-context-menu 通用右键菜单组件使用
前端·javascript·vue.js·vxeui
程序员黑豆1 小时前
鸿蒙应用开发实战:轻松实现列表上拉加载更多
前端·华为·harmonyos
hamber2 小时前
GoGBA 上架半年记
前端
IT_陈寒2 小时前
Python的finally居然不等同于Go的defer,差点坑惨我
前端·人工智能·后端
恋猫de小郭2 小时前
给 AI 的 Agent 实现指南,可控 Agent 的关键
前端·人工智能·ai编程
米码收割机2 小时前
【Python】Flask+SQLite_web 宠物领养系统 (源码+文档)【独一无二】
前端·python·flask