/* ============================================================ 待办事项悬浮窗 Demo 交互逻辑 三组动画: 1. 创建:逐行从左向右进入(stagger) 2. 完成:横线从左往右划过 + 文字变灰 3. 更新:旧行逐行向左移出(高度收拢)→ 新行逐行进入 ============================================================ */ const ENTER_STAGGER = 60; // 进入动画每行间隔 ms const LEAVE_STAGGER = 45; // 移出动画每行间隔 ms const LEAVE_DURATION = 260; // 单行移出动画时长 ms const listEl = document.getElementById('todoList'); const windowEl = document.getElementById('todoWindow'); const counterEl = document.getElementById('todoCounter'); const state = { items: [], busy: false }; /* ---------------- 演示数据 ---------------- */ const SCENARIOS = { initial: [ { text: '梳理对话区布局,确定悬浮窗挂载位置' }, { text: '实现悬浮窗容器:圆角、最小 / 最大宽度' }, { text: '待办行直接铺在窗口上,去掉嵌套卡片' }, { text: '编写创建 / 完成 / 更新三组动画' }, { text: '联调后端 todo 事件,接入真实数据' }, ], updated: [ { text: '复核悬浮窗最大 / 最小宽度表现', done: true }, { text: '把划线动画时序再调顺手一点' }, { text: '超长任务名省略号验证(这一条故意写得特别特别长来测试溢出)' }, { text: '设计文件编辑记录窗口的数据结构' }, { text: '子智能体与后台指令窗口渲染方案' }, { text: '三种主题下核对一遍配色' }, ], long: [ { text: '拉取最新代码并跑一遍冒烟测试' }, { text: '检查待办窗口在窄屏下的最小宽度' }, { text: '把完成动画的横线改成从左向右' }, { text: '补五条以上时的内部滚动' }, { text: '写一条特别长的任务用来验证文字溢出时省略号效果到底行不行' }, { text: '同步更新 AGENTS.md 的布局说明' }, { text: '给悬浮窗预留多窗口上下排布的间距' }, { text: '收拾 demo 控制台,准备演示' }, ], }; /* ---------------- 工具 ---------------- */ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function updateCounter() { const done = state.items.filter((t) => t.done).length; counterEl.textContent = `${done}/${state.items.length}`; } function ensureWindowVisible() { if (!windowEl.hidden) return; windowEl.hidden = false; windowEl.classList.add('window-enter'); windowEl.addEventListener('animationend', () => windowEl.classList.remove('window-enter'), { once: true }); } /* ---------------- 渲染 ---------------- */ function renderTodos(items) { listEl.innerHTML = ''; items.forEach((item, i) => { const li = document.createElement('li'); li.className = 'todo-item is-entering' + (item.done ? ' is-done' : ''); li.style.animationDelay = `${i * ENTER_STAGGER}ms`; const dot = document.createElement('span'); dot.className = 'todo-dot'; const text = document.createElement('span'); text.className = 'todo-text'; text.textContent = item.text; li.append(dot, text); li.addEventListener('animationend', () => { li.classList.remove('is-entering'); li.style.animationDelay = ''; }, { once: true }); listEl.appendChild(li); }); updateCounter(); } /* 让当前列表逐行向左移出,返回动画总耗时 */ async function playLeaveAnimation() { const rows = [...listEl.children]; if (!rows.length) return; rows.forEach((li, i) => { li.style.animationDelay = `${i * LEAVE_STAGGER}ms`; li.classList.add('is-leaving'); }); await wait(rows.length * LEAVE_STAGGER + LEAVE_DURATION + 20); } /* ---------------- 三个核心动作 ---------------- */ /* 1. 创建 / 整表替换(收到新 todo 列表) */ async function replaceTodos(newItems) { if (state.busy) return; state.busy = true; await playLeaveAnimation(); // 旧列表逐行移出 state.items = newItems.map((t) => ({ text: t.text, done: !!t.done })); ensureWindowVisible(); renderTodos(state.items); // 新列表逐行进入 state.busy = false; } /* 若目标行未完全显示,先平滑滚动到可见位置;已可见则立即 resolve */ function scrollRowIntoView(li) { return new Promise((resolve) => { const listRect = listEl.getBoundingClientRect(); const liRect = li.getBoundingClientRect(); const fullyVisible = liRect.top >= listRect.top && liRect.bottom <= listRect.bottom; if (fullyVisible) { resolve(); return; } let done = false; let timer; const finish = () => { if (done) return; done = true; clearTimeout(timer); listEl.removeEventListener('scroll', onScroll); resolve(); }; const onScroll = () => { clearTimeout(timer); timer = setTimeout(finish, 90); // 滚动停止 90ms 后认为到位 }; listEl.addEventListener('scroll', onScroll); li.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); setTimeout(finish, 800); // 兜底,避免意外卡死 }); } /* 2. 完成下一项(先滚动到可见 → 再划线动画) */ async function completeNext() { if (state.busy) return; const idx = state.items.findIndex((t) => !t.done); if (idx === -1) return; state.busy = true; const li = listEl.children[idx]; if (li) await scrollRowIntoView(li); // 未完全显示的先滚出来 state.items[idx].done = true; if (li) { li.classList.add('is-done', 'just-done'); setTimeout(() => li.classList.remove('just-done'), 400); } updateCounter(); state.busy = false; } /* 3. 清空(移出后隐藏窗口) */ async function clearTodos() { if (state.busy || !state.items.length) return; state.busy = true; await playLeaveAnimation(); state.items = []; renderTodos(state.items); windowEl.classList.add('window-leave'); windowEl.addEventListener('animationend', () => { windowEl.classList.remove('window-leave'); windowEl.hidden = true; }, { once: true }); state.busy = false; } /* ---------------- 绑定演示按钮 ---------------- */ document.getElementById('btnCreate').addEventListener('click', () => replaceTodos(SCENARIOS.initial)); document.getElementById('btnComplete').addEventListener('click', completeNext); document.getElementById('btnUpdate').addEventListener('click', () => replaceTodos(SCENARIOS.updated)); document.getElementById('btnLong').addEventListener('click', () => replaceTodos(SCENARIOS.long)); document.getElementById('btnClear').addEventListener('click', clearTodos); /* 页面加载后自动演示一次创建 */ window.addEventListener('load', () => { setTimeout(() => replaceTodos(SCENARIOS.initial), 500); });