|
| 1 | +/** |
| 2 | + * useNoteMarquee — 组头空白起手的划选(批 0-C2·Task 2 自 NoteListView 抽出)。 |
| 3 | + * |
| 4 | + * @ai-context: 划选(REQ-287):组头空白 pointerdown → 起点 = 组内首行在**全局可见序** |
| 5 | + * 中的位次 → 指针当前命中的行带区间(含两端)写入选集。命中判定是 |
| 6 | + * `document.elementFromPoint` → `closest('[id^="note-row-"]')` → |
| 7 | + * `Number(id.replace("note-row-", ""))` —— **隐式依赖 NoteListRow 的行 id |
| 8 | + * 契约**(两端均无类型保障,改动任一侧即静默失效)。这块是**零自动化覆盖** |
| 9 | + * 区域(jsdom 无真实命中),故判定式与语句顺序**逐字不动**。 |
| 10 | + * @ai-context: 生命周期(L9 审查修复的泄漏点,整块搬迁):window 级 pointermove / |
| 11 | + * pointerup / pointercancel / blur **四路监听必须成对注销**;rAF 节流 |
| 12 | + * (每帧最多一次命中判定);`e.buttons === 0` 是"窗口外松手"兜底。 |
| 13 | + * `cleanup` 定义在 `onMove` 之后却被 `onMove` 提前引用(闭包安全)—— |
| 14 | + * **不要重排语句顺序、不要"优化"成 useEffect**。 |
| 15 | + * @ai-context: 边界——折叠组行不可见,不提供划选起点(避免选中不可见数据);区间只取 |
| 16 | + * 可见序切片,折叠组行天然被排除;起点行不在可见序内(异常)整体失效。 |
| 17 | + */ |
| 18 | +import { useCallback } from "react"; |
| 19 | +import type { SectionData } from "../utils/noteSectionModel"; |
| 20 | + |
| 21 | +export interface NoteMarqueeInput { |
| 22 | + /** 展示节(按 scope 找起点组;useNoteSections 产出) */ |
| 23 | + sections: SectionData[]; |
| 24 | + /** 全局可见序(起点位次与区间切片的唯一基准) */ |
| 25 | + visibleOrder: number[]; |
| 26 | + /** 折叠态(裸键——折叠组禁划选起点) */ |
| 27 | + groupFolds: Record<string, boolean>; |
| 28 | + /** 划选写入选集(useNoteListSelection.setSelection) */ |
| 29 | + setSelection: (next: Set<number>) => void; |
| 30 | +} |
| 31 | + |
| 32 | +export function useNoteMarquee({ sections, visibleOrder, groupFolds, setSelection }: NoteMarqueeInput) { |
| 33 | + /** 划选(组头空白起 → 组内首行至当前行带;走既有行命中的全局可见序)。 |
| 34 | + * L9 审查修正:rAF 节流 + pointercancel/blur/松开(buttons=0)即清理—— |
| 35 | + * 不再有"窗口外释放后监听永久残留、悬停任意行改写选区"的泄漏。 */ |
| 36 | + const startMarquee = useCallback((scope: string) => { |
| 37 | + // 折叠组行不可见——不提供划选起点(避免选中不可见数据) |
| 38 | + const foldKey = scope === "none" ? "none" : scope.slice(2); |
| 39 | + if (groupFolds[foldKey] === true) return; |
| 40 | + const section = sections.find((s) => s.scope === scope); |
| 41 | + if (!section || section.items.length === 0) return; |
| 42 | + const startGlobal = visibleOrder.indexOf(section.items[0].id); |
| 43 | + if (startGlobal < 0) return; |
| 44 | + |
| 45 | + let raf = 0; |
| 46 | + const hit = (e: PointerEvent) => { |
| 47 | + const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; |
| 48 | + const rowEl = el?.closest<HTMLElement>('[id^="note-row-"]'); |
| 49 | + if (!rowEl) return; |
| 50 | + const id = Number(rowEl.id.replace("note-row-", "")); |
| 51 | + const gi = visibleOrder.indexOf(id); |
| 52 | + if (gi < 0) return; |
| 53 | + const lo = Math.min(startGlobal, gi); |
| 54 | + const hi = Math.max(startGlobal, gi); |
| 55 | + setSelection(new Set(visibleOrder.slice(lo, hi + 1))); |
| 56 | + }; |
| 57 | + const onMove = (e: PointerEvent) => { |
| 58 | + if (e.buttons === 0) { cleanup(); return; } |
| 59 | + if (raf) return; |
| 60 | + raf = requestAnimationFrame(() => { raf = 0; hit(e); }); |
| 61 | + }; |
| 62 | + const cleanup = () => { |
| 63 | + if (raf) cancelAnimationFrame(raf); |
| 64 | + window.removeEventListener("pointermove", onMove); |
| 65 | + window.removeEventListener("pointerup", cleanup); |
| 66 | + window.removeEventListener("pointercancel", cleanup); |
| 67 | + window.removeEventListener("blur", cleanup); |
| 68 | + }; |
| 69 | + window.addEventListener("pointermove", onMove); |
| 70 | + window.addEventListener("pointerup", cleanup); |
| 71 | + window.addEventListener("pointercancel", cleanup); |
| 72 | + window.addEventListener("blur", cleanup); |
| 73 | + }, [sections, visibleOrder, groupFolds, setSelection]); |
| 74 | + |
| 75 | + return { startMarquee }; |
| 76 | +} |
0 commit comments