Skip to content

Commit 59615d3

Browse files
ClawMiniClawMini
andcommitted
feat: add multi-select feature
- SelectionContext for managing selection state - MultiSelectBar for batch operations (close, move, group, copy) - Keyboard support (Space, Enter, Escape, Cmd+A) - Integration with Fancytree checkbox - External close sync via chrome.tabs.onRemoved Co-authored-by: ClawMini <clawmini@openclaw.ai>
1 parent c275525 commit 59615d3

6 files changed

Lines changed: 658 additions & 8 deletions

File tree

‎docs/PRD.md‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# link-map 多选功能 PRD
2+
3+
## 1. 功能概述
4+
5+
允许用户同时选择树形结构中的多个标签页,进行批量操作(关闭、移动、分组、导出)。
6+
7+
## 2. 用户场景
8+
9+
- **批量清理**:关闭已完成研究的所有相关标签
10+
- **整理**:将相关标签移动到新窗口或父节点
11+
- **批量保存**:选中标签添加书签或复制链接列表
12+
13+
## 3. UI/UX 设计
14+
15+
### 3.1 复选框
16+
- **位置**:图标左侧,始终可见
17+
- **样式**:选中行高亮背景色(便于区分)
18+
- **防误触**:复选框有足够 padding.2 父节点三态
19+
- **Checked**:所有子节点选中
20+
- **Un
21+
22+
### 3checked**:无子节点选中
23+
- **Indeterminate**:1 到 (n-1) 个子节点选中
24+
25+
### 3.3 交互方式
26+
| 操作 | 方式 |
27+
|------|------|
28+
| 单选 | 点击复选框 |
29+
| 连续多选 | Shift + 点击 |
30+
| 任意多选 | Ctrl/Cmd + 点击 |
31+
| 全选 | Ctrl/Cmd + A |
32+
| 取消 | Esc |
33+
| 范围选择 | Shift + 方向键 |
34+
35+
### 3.4 批量操作栏
36+
- 选中 n > 0 时显示
37+
- 显示已选数量
38+
- 显示可用操作
39+
40+
## 4. 功能需求
41+
42+
### 4.1 核心功能
43+
- [ ] 始终可见的复选框 UI
44+
- [ ] 单选/多选功能
45+
- [ ] 父节点 indeterminate 状态
46+
- [ ] 键盘导航支持
47+
- [ ] 批量关闭
48+
- [ ] 批量移动到新窗口
49+
- [ ] 批量移动到指定位置
50+
- [ ] 批量添加到分组
51+
- [ ] 复制链接
52+
53+
### 4.2 状态管理
54+
- 选中项在外部关闭时 **自动移除**
55+
- 选择状态在展开/折叠时保持
56+
57+
### 4.3 快捷键
58+
- `↑/↓`:焦点移动
59+
- `Space`:切换选择
60+
- `Shift + ↑/↓`:范围选择
61+
- `Ctrl/Cmd + A`:全选
62+
- `Esc`:取消选择
63+
- `Enter`:执行主操作
64+
65+
## 5. 非功能需求
66+
67+
### 5.1 性能
68+
- 100+ 节点选中 <100ms
69+
70+
### 5.2 无障碍 (Accessibility)
71+
- `role="listbox"` 容器
72+
- `role="option"` + `aria-selected` 项
73+
- `aria-checked="mixed"` indeterminate 状态
74+
- `aria-live="polite"` 已选数量播报
75+
76+
### 5.3 兼容性
77+
- Chrome / Edge 兼容
78+
- 暗色/亮色主题兼容
79+
80+
---
81+
82+
**状态**:✅ 已通过 ClawMini + Gemini 联合 review
83+
**版本**:1.0
84+
**日期**:2026-03-04

‎docs/TECH_DESIGN.md‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# link-map 多选功能技术方案
2+
3+
## 1. 架构设计
4+
5+
- **SelectionContext**: React Context 管理选择状态
6+
- **TabMasterTree**: Fancytree 集成
7+
- **OperationBar**: 批量操作 UI
8+
9+
## 2. 选择状态接口
10+
11+
```typescript
12+
interface SelectionState {
13+
selectedKeys: Set<string>; // 已选节点 keys
14+
indeterminateKeys: Set<string>; // 半选状态(父节点)
15+
anchorKey: string | null; // Shift 范围选择锚点
16+
}
17+
```
18+
19+
## 3. 范围选择算法
20+
21+
- 使用 `tree.visit()` 遍历可见节点
22+
- 使用 `node.getNextNode(true)` 获取下一个可见节点
23+
- 在锚点和目标节点之间收集所有节点
24+
25+
## 4. Fancytree 集成
26+
27+
- **selectMode**: 2 (标准多选,v1)
28+
- **select 事件**: 同步到 React state
29+
- **防循环**: 使用 flag 防止 React 更新触发 Fancytree 事件
30+
31+
## 5. 批量 Chrome Tabs API
32+
33+
```typescript
34+
// 批量关闭
35+
chrome.tabs.remove([tabIds])
36+
37+
// 批量移动到指定窗口
38+
chrome.tabs.move([tabIds], { windowId, index })
39+
40+
// 移动到新窗口
41+
const win = await chrome.windows.create({});
42+
chrome.tabs.move([tabIds], { windowId: win.id, index: 0 })
43+
```
44+
45+
- 添加 try-catch 错误处理
46+
- 部分失败时提示用户并更新选择集
47+
48+
### 5.1 批量分组
49+
- 使用 `chrome.tabGroups` API
50+
- 创建新分组:`chrome.tabGroups.create({ tabIds })`
51+
- OperationBar 添加"添加到分组"入口
52+
53+
### 5.2 复制链接
54+
- 格式:`URL \n Title` 或 Markdown 链接
55+
- 使用 Clipboard API 或 `document.execCommand('copy')`
56+
- 仅针对 tab 节点,窗口/笔记节点禁用
57+
58+
## 6. 外部关闭同步
59+
60+
- 监听 `chrome.tabs.onRemoved`
61+
- 自动从选择中移除已关闭的标签
62+
- 全部关闭时隐藏 OperationBar
63+
- 部分关闭时重算 indeterminate
64+
65+
## 7. 性能优化
66+
67+
- 使用 `useMemo` / `useCallback` 防止不必要重渲染
68+
- 大批量操作添加进度提示
69+
- **目标:100+ 节点选中 <100ms**
70+
- 选择集用 Set 避免重复
71+
72+
## 8. 键盘支持
73+
74+
| 按键 | 功能 |
75+
|------|------|
76+
| `↑/↓` | 焦点移动 |
77+
| `Space` | 切换选择 |
78+
| `Shift + ↑/↓` | 范围选择 |
79+
| `Ctrl/Cmd + A` | 全选可见节点 |
80+
| `Enter` | 执行主操作(默认操作) |
81+
| `Esc` | 取消选择 |
82+
83+
## 9. OperationBar 行为
84+
85+
- `selectedKeys.size > 0` 时显示
86+
- 显示已选数量:`X tabs selected`
87+
- 显示操作按钮:关闭、移动到...、分组、复制链接
88+
- 窗口/笔记节点禁用"复制链接"
89+
90+
## 10. 无障碍 (Accessibility)
91+
92+
- 容器:`role="listbox"`
93+
- 节点:`role="option"` + `aria-selected`
94+
- 父节点半选:`aria-checked="mixed"`
95+
- 数量播报:`aria-live="polite"` 区域
96+
- 焦点管理:树获得焦点时启用键盘导航
97+
98+
## 11. 文件修改清单
99+
100+
| 文件 | 操作 |
101+
|------|------|
102+
| `src/tree/contexts/SelectionContext.tsx` | 新建 |
103+
| `src/tree/features/tab-master-tree/TabMasterTree.tsx` | 修改 |
104+
| `src/tree/features/tab-master-tree/nodes/tab-node-operations.ts` | 修改 |
105+
| `src/tree/features/operation-bar/OperationBar.tsx` | 修改 |
106+
107+
## 12. 实现顺序
108+
109+
1. **SelectionContext** - 定义选择状态接口
110+
2. **Fancytree 复选框** - 集成 checkbox 到树节点
111+
3. **键盘处理** - Space/Arrow/Shift+Click
112+
4. **OperationBar** - 批量操作 UI
113+
5. **批量 API** - 关闭/移动实现
114+
6. **外部同步** - tabs.onRemoved 监听
115+
116+
---
117+
118+
**状态**:✅ Gemini + ClawMini 联合 review 通过
119+
**版本**:1.0
120+
**日期**:2026-03-04
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import React, {
2+
createContext,
3+
useCallback,
4+
useContext,
5+
useEffect,
6+
useMemo,
7+
useState,
8+
} from 'react';
9+
10+
import browser from 'webextension-polyfill';
11+
12+
export interface SelectionContextValue {
13+
selectedKeys: Set<string>;
14+
indeterminateKeys: Set<string>;
15+
anchorKey: string | null;
16+
toggleSelection: (key: string, allKeys?: string[]) => void;
17+
selectRange: (endKey: string, allKeys: string[]) => void;
18+
selectAll: (allKeys: string[]) => void;
19+
clearSelection: () => void;
20+
isSelected: (key: string) => boolean;
21+
}
22+
23+
const SelectionContext = createContext<SelectionContextValue | null>(null);
24+
25+
const emptySet = new Set<string>();
26+
27+
export function SelectionProvider({ children }: { children: React.ReactNode }) {
28+
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(() => new Set());
29+
const [indeterminateKeys, setIndeterminateKeys] = useState<Set<string>>(
30+
() => new Set(),
31+
);
32+
const [anchorKey, setAnchorKey] = useState<string | null>(null);
33+
34+
const clearSelection = useCallback(() => {
35+
setSelectedKeys(new Set());
36+
setIndeterminateKeys(new Set());
37+
setAnchorKey(null);
38+
}, []);
39+
40+
const isSelected = useCallback(
41+
(key: string) => selectedKeys.has(key),
42+
[selectedKeys],
43+
);
44+
45+
const toggleSelection = useCallback((key: string, allKeys?: string[]) => {
46+
setSelectedKeys((prev) => {
47+
const next = new Set(prev);
48+
if (next.has(key)) {
49+
next.delete(key);
50+
} else {
51+
next.add(key);
52+
}
53+
return next;
54+
});
55+
setIndeterminateKeys((prev) => {
56+
const next = new Set(prev);
57+
next.delete(key);
58+
return next;
59+
});
60+
setAnchorKey(key);
61+
}, []);
62+
63+
const selectRange = useCallback(
64+
(endKey: string, allKeys: string[]) => {
65+
const anchor = anchorKey;
66+
if (anchor == null || allKeys.length === 0) {
67+
setSelectedKeys((prev) => new Set(prev).add(endKey));
68+
setAnchorKey(endKey);
69+
return;
70+
}
71+
const startIdx = allKeys.indexOf(anchor);
72+
const endIdx = allKeys.indexOf(endKey);
73+
if (startIdx === -1 || endIdx === -1) {
74+
setSelectedKeys((prev) => new Set(prev).add(endKey));
75+
setAnchorKey(endKey);
76+
return;
77+
}
78+
const low = Math.min(startIdx, endIdx);
79+
const high = Math.max(startIdx, endIdx);
80+
const rangeKeys = allKeys.slice(low, high + 1);
81+
setSelectedKeys(new Set(rangeKeys));
82+
setIndeterminateKeys(emptySet);
83+
},
84+
[anchorKey],
85+
);
86+
87+
const selectAll = useCallback((allKeys: string[]) => {
88+
setSelectedKeys(new Set(allKeys));
89+
setIndeterminateKeys(emptySet);
90+
setAnchorKey(allKeys[0] ?? null);
91+
}, []);
92+
93+
useEffect(() => {
94+
const listener = (tabId: number) => {
95+
const key = String(tabId);
96+
setSelectedKeys((prev) => {
97+
if (!prev.has(key)) return prev;
98+
const next = new Set(prev);
99+
next.delete(key);
100+
return next;
101+
});
102+
setIndeterminateKeys((prev) => {
103+
if (!prev.has(key)) return prev;
104+
const next = new Set(prev);
105+
next.delete(key);
106+
return next;
107+
});
108+
setAnchorKey((prev) => (prev === key ? null : prev));
109+
};
110+
browser.tabs.onRemoved.addListener(listener);
111+
return () => {
112+
browser.tabs.onRemoved.removeListener(listener);
113+
};
114+
}, []);
115+
116+
const value = useMemo<SelectionContextValue>(
117+
() => ({
118+
selectedKeys,
119+
indeterminateKeys,
120+
anchorKey,
121+
toggleSelection,
122+
selectRange,
123+
selectAll,
124+
clearSelection,
125+
isSelected,
126+
}),
127+
[
128+
selectedKeys,
129+
indeterminateKeys,
130+
anchorKey,
131+
toggleSelection,
132+
selectRange,
133+
selectAll,
134+
clearSelection,
135+
isSelected,
136+
],
137+
);
138+
139+
return (
140+
<SelectionContext.Provider value={value}>
141+
{children}
142+
</SelectionContext.Provider>
143+
);
144+
}
145+
146+
export function useSelection(): SelectionContextValue {
147+
const ctx = useContext(SelectionContext);
148+
if (ctx == null) {
149+
throw new Error('useSelection must be used within SelectionProvider');
150+
}
151+
return ctx;
152+
}

0 commit comments

Comments
 (0)