Skip to content

Commit 64fd477

Browse files
committed
refactor(ui): 拆 NoteListView 第 5 步 — 划选 window 监听 hook
1 parent c10c8e8 commit 64fd477

3 files changed

Lines changed: 81 additions & 42 deletions

File tree

‎app/src/components/NoteListView.tsx‎

Lines changed: 4 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { useNoteOrders } from "../hooks/useNoteOrders";
2525
import { useNoteMoves } from "../hooks/useNoteMoves";
2626
// 批 0-C2:多选三通道 + 菜单三态 + 唯一 Esc handler(可见序镜像同文件)
2727
import { useNoteListSelection } from "../hooks/useNoteListSelection";
28+
// 批 0-C2:划选(组头空白起手;window 监听 + elementFromPoint 命中——整块搬迁)
29+
import { useNoteMarquee } from "../hooks/useNoteMarquee";
2830
import NoteListRow from "./NoteListRow";
2931
import NoteTreeSection from "./NoteTreeSection";
3032
import NoteRowContextMenu from "./NoteRowContextMenu";
@@ -148,47 +150,8 @@ export default function NoteListView({
148150
notes, treeMode, manualOrders, manualBaseOf, saveOrder, onNoteMoved, onCleanNotice, closeBatchMenu,
149151
});
150152

151-
/** 划选(组头空白起 → 组内首行至当前行带;走既有行命中的全局可见序)。
152-
* L9 审查修正:rAF 节流 + pointercancel/blur/松开(buttons=0)即清理——
153-
* 不再有"窗口外释放后监听永久残留、悬停任意行改写选区"的泄漏。 */
154-
const startMarquee = useCallback((scope: string) => {
155-
// 折叠组行不可见——不提供划选起点(避免选中不可见数据)
156-
const foldKey = scope === "none" ? "none" : scope.slice(2);
157-
if (groupFolds[foldKey] === true) return;
158-
const section = sections.find((s) => s.scope === scope);
159-
if (!section || section.items.length === 0) return;
160-
const startGlobal = visibleOrder.indexOf(section.items[0].id);
161-
if (startGlobal < 0) return;
162-
163-
let raf = 0;
164-
const hit = (e: PointerEvent) => {
165-
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
166-
const rowEl = el?.closest<HTMLElement>('[id^="note-row-"]');
167-
if (!rowEl) return;
168-
const id = Number(rowEl.id.replace("note-row-", ""));
169-
const gi = visibleOrder.indexOf(id);
170-
if (gi < 0) return;
171-
const lo = Math.min(startGlobal, gi);
172-
const hi = Math.max(startGlobal, gi);
173-
setSelection(new Set(visibleOrder.slice(lo, hi + 1)));
174-
};
175-
const onMove = (e: PointerEvent) => {
176-
if (e.buttons === 0) { cleanup(); return; }
177-
if (raf) return;
178-
raf = requestAnimationFrame(() => { raf = 0; hit(e); });
179-
};
180-
const cleanup = () => {
181-
if (raf) cancelAnimationFrame(raf);
182-
window.removeEventListener("pointermove", onMove);
183-
window.removeEventListener("pointerup", cleanup);
184-
window.removeEventListener("pointercancel", cleanup);
185-
window.removeEventListener("blur", cleanup);
186-
};
187-
window.addEventListener("pointermove", onMove);
188-
window.addEventListener("pointerup", cleanup);
189-
window.addEventListener("pointercancel", cleanup);
190-
window.addEventListener("blur", cleanup);
191-
}, [sections, visibleOrder, groupFolds]);
153+
// 划选(组头空白起手:window 四路监听 + elementFromPoint 行命中)——整块搬迁见 hooks/useNoteMarquee
154+
const { startMarquee } = useNoteMarquee({ sections, visibleOrder, groupFolds, setSelection });
192155

193156
const rowAccent = (n: Note) => paletteHex(noteColors?.[n.id] ?? null, theme);
194157

‎app/src/hooks/useNoteMarquee.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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+
}

‎docs/standards/line-limit-exemptions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@
2424
| app/src-tauri/src/note_filter.rs | 642 | 超硬限(>600 行),不允许豁免 —— v0.6.0 M1(REQ-082/085):笔记过滤域(过滤链 + AI 判定应用 + 画面要点净化)内聚于单一管线(双出口一致性由构造保证);AI 部分已按登记计划拆至 note_filter_ai.rs | 若再增长:净化链拆至 note_filter_purify.rs |
2525
| app/src-tauri/src/artifact_templates.rs | 632 | 超硬限(>600 行),不允许豁免 —— v0.5.0 M7(REQ-052):五档案模板函数(讲义/步骤卡/摘要/对话纪要/会议纪要)内聚于同一模板域,各模板共享原料注入签名;v0.9.0 M5 叙事变体再增 | 若再增长:会议/访谈模板拆至 artifact_templates_meeting.rs |
2626
| app/src-tauri/src/video_profile.rs | 628 | 超硬限(>600 行),不允许豁免 —— v0.5.0 M1(REQ-043):档案域(类型/检测投票/记忆偏好/JSON IO)内聚;档案常量数据已拆至 video_profile_data.rs;v0.9.0 M1 记忆库 kind 映射迁移 + v0.11.5 Task 5 四象限记忆后置判定(apply_profile_memory)再增;v0.13.6(REQ-222)领域记忆独立通道(DomainMemoryEntry/remember_domain/lookup_domain)+ platform_form 字段再增 | 若再增长:检测投票与记忆偏好拆至 video_profile_detect.rs |
27-
| app/src/components/SessionListPanel.tsx | 604 | 超硬限(>600 行),不允许豁免 —— v0.20.9 批 4(REQ-313)列表交互重写(选择模式/行右键/行内改名/批量栏)+ 审查修复轮 3(全选可见行基准/pending)净增——2026-09-09 实测纠偏(登记值 491 过期);**超 600 硬限随 TD-2026-09-09-D 登记** | **超硬限必须拆**:批量操作栏与选择模式拆至 SessionSelectionToolbar.tsx(列表行已拆 SessionListRow.tsx) |
2827

2928
## 301–600 档(须登记)
3029

3130
| 文件 | 行数 | 豁免理由 | 拆分计划 |
3231
|---|---|---|---|
32+
| app/src/components/SessionListPanel.tsx | 598 | 超硬限(>600 行),不允许豁免 —— v0.20.9 批 4(REQ-313)列表交互重写(选择模式/行右键/行内改名/批量栏)+ 审查修复轮 3(全选可见行基准/pending)净增——2026-09-09 实测纠偏(登记值 491 过期);**超 600 硬限随 TD-2026-09-09-D 登记** | **超硬限必须拆**:批量操作栏与选择模式拆至 SessionSelectionToolbar.tsx(列表行已拆 SessionListRow.tsx) |
3333
| app/src-tauri/src/commands.rs | 597 | 命令装配域(AppState + 通用命令 + 导入管线编排);登记值 466 过期快照——2026-09-09 实测纠偏(含批 7 delete_note 结果契约 +12;600 硬限内压线) | 若再增长:导入管线命令拆至 commands_import.rs(既有登记计划) |
3434
| app/src/pages/ChatPage.tsx | 593 | AI 对话页编排;批 1(REQ-306/307)active 门控/终态订阅接线净增——2026-09-09 实测纠偏(登记值 529 过期) | 若再增长:任务工具条与发起流拆至 ChatTasksToolbar.tsx |
3535
| app/src-tauri/src/lib.rs | 577 | crate 根 321 `mod` + 16 `#[cfg]` = **337 行地板**;注册清单已移至 `app_commands.rs`(**数据文件**,同属 300–600 豁免带)—— 结构性下界,非欠账 | 已完成(批 0-C3 Task 1,2026-09-11);余下 161 行模块理由注释 + 装配逻辑,无进一步拆分标的 |

0 commit comments

Comments
 (0)