@hardanonymous/marquee-selector 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/MarqueeSelector.d.ts +84 -0
- package/dist/MarqueeSelector.d.ts.map +1 -0
- package/dist/index.cjs +211 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.iife.js +210 -0
- package/dist/index.iife.js.map +1 -0
- package/dist/index.mjs +203 -0
- package/dist/index.mjs.map +1 -0
- package/dist/marquee-selector.css +21 -0
- package/docs/README.md +621 -0
- package/package.json +63 -0
package/docs/README.md
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
# MarqueeSelector 框選功能文檔
|
|
2
|
+
|
|
3
|
+
## 概述
|
|
4
|
+
|
|
5
|
+
`MarqueeSelector` 是一個框架無關的 JavaScript/TypeScript 框選工具,允許使用者通過滑鼠拖曳選取頁面上的元素。它提供了高度的靈活性和可定制性,支援多個目標配置、文字選取保護、拖拽元素保護等功能。
|
|
6
|
+
|
|
7
|
+
## 特性
|
|
8
|
+
|
|
9
|
+
✅ **框架無關**:純 Vanilla JavaScript 實現,可在任何前端框架中使用
|
|
10
|
+
✅ **多目標支援**:可同時追蹤多組不同的選取目標,各有獨立的回調
|
|
11
|
+
✅ **智能保護機制**:
|
|
12
|
+
- 自動保護可拖拽元素(`draggable="true"`)
|
|
13
|
+
- 保護文字選取區域(input、textarea、contenteditable 等)
|
|
14
|
+
✅ **完整生命週期**:提供 `onSelectionStart`、`onSelectionChange`、`onSelectionEnd`、`onClearClick` 回調
|
|
15
|
+
✅ **精確碰撞檢測**:支援滾動容器,使用 client 座標進行精確的碰撞檢測
|
|
16
|
+
✅ **記憶體優化**:使用 WeakMap 快取選取狀態,避免記憶體洩漏
|
|
17
|
+
✅ **CSS 可定制**:通過 CSS 變數輕鬆定制外觀
|
|
18
|
+
|
|
19
|
+
## 安裝
|
|
20
|
+
|
|
21
|
+
將 `MarqueeSelector.ts` 和 `marquee-selector.css` 放入專案中:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { MarqueeSelector } from '@/utils/MarqueeSelector';
|
|
25
|
+
import '@/styles/marquee-selector.css';
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 基本使用
|
|
29
|
+
|
|
30
|
+
### 1. 初始化
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
const marquee = new MarqueeSelector({
|
|
34
|
+
container: document.body, // 或 '.container' 選擇器
|
|
35
|
+
allowTextSelectionOn: ['p', '.text-content'] // 可選:額外允許文字選取的元素
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 2. 添加目標配置
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
marquee.addTarget({
|
|
43
|
+
selector: '.selectable-item', // CSS 選擇器或 HTMLElement
|
|
44
|
+
|
|
45
|
+
onSelectionStart: (targetElement) => {
|
|
46
|
+
console.log('開始框選:', targetElement);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
onSelectionChange: (selectedElements) => {
|
|
50
|
+
console.log('選取變化:', selectedElements);
|
|
51
|
+
// 更新 UI 狀態
|
|
52
|
+
selectedElements.forEach(el => el.classList.add('selected'));
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
onSelectionEnd: (selectedElements) => {
|
|
56
|
+
console.log('框選結束:', selectedElements);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
onClearClick: (selectedElements) => {
|
|
60
|
+
console.log('點擊空白處,清除選取:', selectedElements);
|
|
61
|
+
// 清除選取狀態
|
|
62
|
+
selectedElements.forEach(el => el.classList.remove('selected'));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3. 啟用框選
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
marquee.enable();
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 4. 清理資源
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
marquee.destroy();
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## API 參考
|
|
80
|
+
|
|
81
|
+
### MarqueeSelectorOptions
|
|
82
|
+
|
|
83
|
+
初始化選項配置:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
interface MarqueeSelectorOptions {
|
|
87
|
+
/**
|
|
88
|
+
* 容器元素的實例或選擇器字串
|
|
89
|
+
* 這是滑鼠事件監聽的目標,也是計算相對座標的基準
|
|
90
|
+
*/
|
|
91
|
+
container: HTMLElement | string;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 允許文字選取的元素選擇器(這些元素上不會啟動框選)
|
|
95
|
+
* 預設已包含: input, textarea, [contenteditable], pre, code
|
|
96
|
+
* @example ['p', '.text-content', '[contenteditable]']
|
|
97
|
+
*/
|
|
98
|
+
allowTextSelectionOn?: string[];
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### MarqueeTargetConfig
|
|
103
|
+
|
|
104
|
+
目標配置:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
interface MarqueeTargetConfig {
|
|
108
|
+
/**
|
|
109
|
+
* 可以是單一或多個 CSS 選擇器,或直接傳入 HTMLElement(或其陣列)
|
|
110
|
+
* @example '.item'
|
|
111
|
+
* @example ['.item-a', '.item-b']
|
|
112
|
+
* @example document.querySelector('.item')
|
|
113
|
+
* @example [element1, element2]
|
|
114
|
+
*/
|
|
115
|
+
selector: string | string[] | HTMLElement | HTMLElement[];
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 框選開始時的回調(首次移動滑鼠時觸發)
|
|
119
|
+
* @param targetElement 滑鼠按下的目標元素
|
|
120
|
+
*/
|
|
121
|
+
onSelectionStart?: (targetElement: HTMLElement) => void;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 選取變化時的回調(框選過程中持續觸發)
|
|
125
|
+
* @param selectedElements 當前選取的元素陣列
|
|
126
|
+
*/
|
|
127
|
+
onSelectionChange?: (selectedElements: HTMLElement[]) => void;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 框選結束時的回調(滑鼠放開時觸發)
|
|
131
|
+
* @param selectedElements 最終選取的元素陣列
|
|
132
|
+
*/
|
|
133
|
+
onSelectionEnd?: (selectedElements: HTMLElement[]) => void;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 點擊空白處時的回調(框選結束後,點擊非選取元素時觸發)
|
|
137
|
+
* 使用者可在此回調中決定是否清除選取、執行其他操作等
|
|
138
|
+
* @param selectedElements 當前選取的元素陣列
|
|
139
|
+
*/
|
|
140
|
+
onClearClick?: (selectedElements: HTMLElement[]) => void;
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 公開方法
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
class MarqueeSelector {
|
|
148
|
+
/**
|
|
149
|
+
* 啟用框選功能
|
|
150
|
+
*/
|
|
151
|
+
enable(): void;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 停用框選功能
|
|
155
|
+
*/
|
|
156
|
+
disable(): void;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 檢查是否啟用
|
|
160
|
+
*/
|
|
161
|
+
isEnabled(): boolean;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 動態新增目標配置
|
|
165
|
+
*/
|
|
166
|
+
addTarget(config: MarqueeTargetConfig): void;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 銷毀實例,移除所有事件監聽器
|
|
170
|
+
*/
|
|
171
|
+
destroy(): void;
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## 進階使用
|
|
176
|
+
|
|
177
|
+
### 多目標配置
|
|
178
|
+
|
|
179
|
+
可以為不同類型的元素設定不同的選取邏輯:
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
// 配置 A:拖曳項目
|
|
183
|
+
marquee.addTarget({
|
|
184
|
+
selector: '.draggable-item',
|
|
185
|
+
onSelectionChange: (selected) => {
|
|
186
|
+
selectedItems.value = selected.map(el => el.dataset.id);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// 配置 B:樹節點
|
|
191
|
+
marquee.addTarget({
|
|
192
|
+
selector: '.tree-node',
|
|
193
|
+
onSelectionChange: (selected) => {
|
|
194
|
+
selectedNodes.value = selected.map(el => el.dataset.nodeId);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Vue 3 整合
|
|
200
|
+
|
|
201
|
+
```vue
|
|
202
|
+
<script setup lang="ts">
|
|
203
|
+
import { ref, onMounted, onUnmounted } from 'vue';
|
|
204
|
+
import { MarqueeSelector } from '@/utils/MarqueeSelector';
|
|
205
|
+
import '@/styles/marquee-selector.css';
|
|
206
|
+
|
|
207
|
+
const containerRef = ref<HTMLElement>();
|
|
208
|
+
const selectedIds = ref<number[]>([]);
|
|
209
|
+
let marquee: MarqueeSelector | null = null;
|
|
210
|
+
|
|
211
|
+
onMounted(() => {
|
|
212
|
+
if (containerRef.value) {
|
|
213
|
+
marquee = new MarqueeSelector({
|
|
214
|
+
container: containerRef.value
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
marquee.addTarget({
|
|
218
|
+
selector: '.item',
|
|
219
|
+
onSelectionChange: (elements) => {
|
|
220
|
+
selectedIds.value = elements.map(el =>
|
|
221
|
+
Number(el.getAttribute('data-id'))
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
onClearClick: () => {
|
|
225
|
+
selectedIds.value = [];
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
marquee.enable();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
onUnmounted(() => {
|
|
234
|
+
marquee?.destroy();
|
|
235
|
+
});
|
|
236
|
+
</script>
|
|
237
|
+
|
|
238
|
+
<template>
|
|
239
|
+
<div ref="containerRef">
|
|
240
|
+
<div
|
|
241
|
+
v-for="item in items"
|
|
242
|
+
:key="item.id"
|
|
243
|
+
:data-id="item.id"
|
|
244
|
+
class="item"
|
|
245
|
+
:class="{ selected: selectedIds.includes(item.id) }"
|
|
246
|
+
>
|
|
247
|
+
{{ item.name }}
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</template>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### React 整合
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
import { useEffect, useRef, useState } from 'react';
|
|
257
|
+
import { MarqueeSelector } from './utils/MarqueeSelector';
|
|
258
|
+
import './styles/marquee-selector.css';
|
|
259
|
+
|
|
260
|
+
function App() {
|
|
261
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
262
|
+
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
263
|
+
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (!containerRef.current) return;
|
|
266
|
+
|
|
267
|
+
const marquee = new MarqueeSelector({
|
|
268
|
+
container: containerRef.current
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
marquee.addTarget({
|
|
272
|
+
selector: '.item',
|
|
273
|
+
onSelectionChange: (elements) => {
|
|
274
|
+
const ids = elements.map(el =>
|
|
275
|
+
Number(el.getAttribute('data-id'))
|
|
276
|
+
);
|
|
277
|
+
setSelectedIds(ids);
|
|
278
|
+
},
|
|
279
|
+
onClearClick: () => {
|
|
280
|
+
setSelectedIds([]);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
marquee.enable();
|
|
285
|
+
|
|
286
|
+
return () => marquee.destroy();
|
|
287
|
+
}, []);
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div ref={containerRef}>
|
|
291
|
+
{items.map(item => (
|
|
292
|
+
<div
|
|
293
|
+
key={item.id}
|
|
294
|
+
data-id={item.id}
|
|
295
|
+
className={`item ${selectedIds.includes(item.id) ? 'selected' : ''}`}
|
|
296
|
+
>
|
|
297
|
+
{item.name}
|
|
298
|
+
</div>
|
|
299
|
+
))}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### 與拖拽功能整合
|
|
306
|
+
|
|
307
|
+
```vue
|
|
308
|
+
<template>
|
|
309
|
+
<div ref="containerRef">
|
|
310
|
+
<div
|
|
311
|
+
v-for="item in items"
|
|
312
|
+
:key="item.id"
|
|
313
|
+
:data-id="item.id"
|
|
314
|
+
class="item"
|
|
315
|
+
:class="{ selected: selectedIds.includes(item.id) }"
|
|
316
|
+
draggable="true"
|
|
317
|
+
@dragstart="onDragStart($event, item)"
|
|
318
|
+
>
|
|
319
|
+
{{ item.name }}
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
</template>
|
|
323
|
+
|
|
324
|
+
<script setup lang="ts">
|
|
325
|
+
// MarqueeSelector 會自動偵測 draggable="true" 的元素
|
|
326
|
+
// 在這些元素上按下滑鼠時,不會啟動框選,而是觸發拖拽
|
|
327
|
+
|
|
328
|
+
const onDragStart = (event: DragEvent, item: Item) => {
|
|
329
|
+
// 如果該項目未被選取,則只拖拽自己
|
|
330
|
+
if (!selectedIds.value.includes(item.id)) {
|
|
331
|
+
selectedIds.value = [item.id];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 拖拽所有選取的項目
|
|
335
|
+
const draggedItems = items.filter(i =>
|
|
336
|
+
selectedIds.value.includes(i.id)
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
event.dataTransfer?.setData('text/plain',
|
|
340
|
+
JSON.stringify(draggedItems)
|
|
341
|
+
);
|
|
342
|
+
};
|
|
343
|
+
</script>
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## 工作原理
|
|
347
|
+
|
|
348
|
+
### 1. 事件流程
|
|
349
|
+
|
|
350
|
+
#### 主要事件流程
|
|
351
|
+
|
|
352
|
+
```mermaid
|
|
353
|
+
flowchart TD
|
|
354
|
+
Start([MouseDown 左鍵]) --> CheckPriority{檢查文字選取或拖拽?<br/>isTextSelectionAllowed<br/>或 draggable 元素}
|
|
355
|
+
CheckPriority -->|是<br/>優先處理| Return[返回,不啟動框選<br/>允許文字選取或拖拽]
|
|
356
|
+
CheckPriority -->|否<br/>可以框選| Record[記錄起始座標<br/>監聽 mousemove/mouseup<br/>hasStarted = false]
|
|
357
|
+
Record --> FirstMove{MouseMove<br/>第一次移動?}
|
|
358
|
+
FirstMove -->|是| SetStarted[hasStarted = true<br/>觸發 onSelectionStart]
|
|
359
|
+
SetStarted --> UpdateBox[更新框選框位置]
|
|
360
|
+
FirstMove -->|否| UpdateBox
|
|
361
|
+
UpdateBox --> Collision[碰撞檢測<br/>AABB Algorithm]
|
|
362
|
+
Collision --> Changed{選取變化?}
|
|
363
|
+
Changed -->|是| TriggerChange[觸發 onSelectionChange]
|
|
364
|
+
Changed -->|否| ContinueMove{繼續移動?}
|
|
365
|
+
TriggerChange --> ContinueMove
|
|
366
|
+
ContinueMove -->|是| FirstMove
|
|
367
|
+
ContinueMove -->|否| MouseUp[MouseUp]
|
|
368
|
+
MouseUp --> HasStartedCheck{hasStarted?}
|
|
369
|
+
HasStartedCheck -->|否| End1[結束]
|
|
370
|
+
HasStartedCheck -->|是| TriggerEnd[觸發 onSelectionEnd]
|
|
371
|
+
TriggerEnd --> HasClearHandler{有 onClearClick<br/>且有選取?}
|
|
372
|
+
HasClearHandler -->|否| End2[結束]
|
|
373
|
+
HasClearHandler -->|是| BindClick[綁定 click 事件]
|
|
374
|
+
BindClick --> WaitClick[等待使用者點擊]
|
|
375
|
+
WaitClick --> Click[Click 事件]
|
|
376
|
+
Click --> IsEmpty{點擊空白處?}
|
|
377
|
+
IsEmpty -->|是| TriggerClear[觸發 onClearClick<br/>清空快取]
|
|
378
|
+
IsEmpty -->|否| SkipClear[跳過清除]
|
|
379
|
+
TriggerClear --> UnbindClick[移除 click 監聽器]
|
|
380
|
+
SkipClear --> UnbindClick
|
|
381
|
+
UnbindClick --> End3[結束]
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
#### 保護機制檢查流程
|
|
385
|
+
|
|
386
|
+
**主要決策流程(優先級:文字選取 = 拖拽 > 框選)**
|
|
387
|
+
|
|
388
|
+
```mermaid
|
|
389
|
+
flowchart TD
|
|
390
|
+
Start([MouseDown 點擊目標]) --> CallCheck[檢查優先級]
|
|
391
|
+
CallCheck --> TextCheck{isTextSelectionAllowed?}
|
|
392
|
+
TextCheck -->|是| Return[返回,不啟動框選]
|
|
393
|
+
TextCheck -->|否| DragCheck{target.draggable 或<br/>最近的 draggable 祖先?}
|
|
394
|
+
DragCheck -->|是| Return
|
|
395
|
+
DragCheck -->|否| StartSelection[啟動框選]
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**isTextSelectionAllowed 內部判斷邏輯**
|
|
399
|
+
|
|
400
|
+
```mermaid
|
|
401
|
+
flowchart TD
|
|
402
|
+
Start([isTextSelectionAllowed<br/>檢查目標元素]) --> MatchSelector{匹配<br/>allowTextSelectionSelectors<br/>選擇器?}
|
|
403
|
+
MatchSelector -->|否| CheckUserSelect{CSS userSelect<br/>!== 'none'?}
|
|
404
|
+
MatchSelector -->|是| ReturnTrue1[返回 true]
|
|
405
|
+
CheckUserSelect -->|否| ReturnFalse[返回 false]
|
|
406
|
+
CheckUserSelect -->|是| HasTextNode{包含文字節點?}
|
|
407
|
+
HasTextNode -->|否| ReturnFalse
|
|
408
|
+
HasTextNode -->|是| ReturnTrue2[返回 true]
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
#### onClearClick 觸發機制
|
|
412
|
+
|
|
413
|
+
```mermaid
|
|
414
|
+
flowchart TD
|
|
415
|
+
Start([框選結束 MouseUp]) --> HasHandler{任何 target<br/>有 onClearClick?}
|
|
416
|
+
HasHandler -->|否| End1[結束]
|
|
417
|
+
HasHandler -->|是| HasSelection{任何 target<br/>有選取項目?}
|
|
418
|
+
HasSelection -->|否| End2[結束]
|
|
419
|
+
HasSelection -->|是| Bind[setTimeout 綁定<br/>click 事件監聽器]
|
|
420
|
+
Bind --> Wait[等待點擊...]
|
|
421
|
+
Wait --> Clicked[Click 事件觸發]
|
|
422
|
+
Clicked --> ForEach[遍歷每個 target]
|
|
423
|
+
ForEach --> HasClearClick{target 有<br/>onClearClick?}
|
|
424
|
+
HasClearClick -->|否| MoreTargets{還有 target?}
|
|
425
|
+
HasClearClick -->|是| HasItems{target 有<br/>選取項目?}
|
|
426
|
+
HasItems -->|否| MoreTargets
|
|
427
|
+
HasItems -->|是| IsClickOnTarget{點擊在<br/>選取範圍內?}
|
|
428
|
+
IsClickOnTarget -->|是| MoreTargets
|
|
429
|
+
IsClickOnTarget -->|否| TriggerClear[觸發 onClearClick<br/>清空該 target 快取]
|
|
430
|
+
TriggerClear --> MoreTargets
|
|
431
|
+
MoreTargets -->|是| ForEach
|
|
432
|
+
MoreTargets -->|否| Unbind[移除 click 監聽器]
|
|
433
|
+
Unbind --> End3[結束]
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### 2. 碰撞檢測
|
|
437
|
+
|
|
438
|
+
使用 `getBoundingClientRect()` 進行精確的 AABB (Axis-Aligned Bounding Box) 碰撞檢測:
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
const isIntersecting = !(
|
|
442
|
+
elRect.right < boxClientLeft ||
|
|
443
|
+
elRect.left > boxClientRight ||
|
|
444
|
+
elRect.bottom < boxClientTop ||
|
|
445
|
+
elRect.top > boxClientBottom
|
|
446
|
+
);
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### 3. 座標轉換
|
|
450
|
+
|
|
451
|
+
支援滾動容器,自動處理相對座標與 client 座標的轉換:
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
// 相對座標 → Client 座標
|
|
455
|
+
const boxClientLeft = relLeft - container.scrollLeft + containerRect.left;
|
|
456
|
+
const boxClientTop = relTop - container.scrollTop + containerRect.top;
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### 4. 內建保護機制
|
|
460
|
+
|
|
461
|
+
#### 文字選取與拖拽保護(平級優先)
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
// 文字選取和拖拽都優先於框選
|
|
465
|
+
if (this.isTextSelectionAllowed(target) || target.draggable || target.closest('[draggable="true"]')) {
|
|
466
|
+
return; // 允許文字選取或拖拽,不啟動框選
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
#### 文字選取保護
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
// 預設保護的元素
|
|
474
|
+
const defaultTextSelectors = [
|
|
475
|
+
'input',
|
|
476
|
+
'textarea',
|
|
477
|
+
'[contenteditable]',
|
|
478
|
+
'pre',
|
|
479
|
+
'code'
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
// 檢查是否應該允許文字選取
|
|
483
|
+
private isTextSelectionAllowed(target: HTMLElement): boolean {
|
|
484
|
+
// 檢查選擇器匹配
|
|
485
|
+
for (const selector of this.allowTextSelectionSelectors) {
|
|
486
|
+
if (target.matches(selector) || target.closest(selector)) {
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// 檢查 CSS userSelect 屬性
|
|
492
|
+
const computedStyle = window.getComputedStyle(target);
|
|
493
|
+
if (computedStyle.userSelect !== 'none') {
|
|
494
|
+
// 檢查是否包含文字節點
|
|
495
|
+
// ...
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
## CSS 自定義
|
|
503
|
+
|
|
504
|
+
`marquee-selector.css` 提供了以下 CSS 變數:
|
|
505
|
+
|
|
506
|
+
```css
|
|
507
|
+
.marquee-selection-box {
|
|
508
|
+
--marquee-bg: rgba(0, 123, 255, 0.1);
|
|
509
|
+
--marquee-border: 1px solid rgba(0, 123, 255, 0.5);
|
|
510
|
+
|
|
511
|
+
position: absolute;
|
|
512
|
+
background-color: var(--marquee-bg);
|
|
513
|
+
border: var(--marquee-border);
|
|
514
|
+
pointer-events: none;
|
|
515
|
+
z-index: 9999;
|
|
516
|
+
display: none;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.marquee-selection-box.active {
|
|
520
|
+
display: block;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
.disable-user-select,
|
|
524
|
+
.disable-user-select * {
|
|
525
|
+
user-select: none !important;
|
|
526
|
+
-webkit-user-select: none !important;
|
|
527
|
+
-moz-user-select: none !important;
|
|
528
|
+
-ms-user-select: none !important;
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### 自定義外觀
|
|
533
|
+
|
|
534
|
+
```css
|
|
535
|
+
/* 在你的 CSS 中覆寫變數 */
|
|
536
|
+
.marquee-selection-box {
|
|
537
|
+
--marquee-bg: rgba(255, 0, 0, 0.15);
|
|
538
|
+
--marquee-border: 2px dashed red;
|
|
539
|
+
border-radius: 4px;
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
## 效能考量
|
|
544
|
+
|
|
545
|
+
### 1. WeakMap 快取
|
|
546
|
+
|
|
547
|
+
使用 `WeakMap` 儲存每個 target 的選取狀態,當 target 配置被垃圾回收時,自動清理快取:
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
private targetSelectionCache = new WeakMap<MarqueeTargetConfig, HTMLElement[]>();
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
### 2. 變化檢測
|
|
554
|
+
|
|
555
|
+
只在選取狀態真正改變時才觸發 `onSelectionChange`:
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
const targetChanged =
|
|
559
|
+
selectedForTarget.length !== lastForTarget.length ||
|
|
560
|
+
selectedForTarget.some((el, i) => el !== lastForTarget[i]);
|
|
561
|
+
|
|
562
|
+
if (targetChanged) {
|
|
563
|
+
// 觸發回調
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### 3. 事件委派
|
|
568
|
+
|
|
569
|
+
使用綁定方法避免重複創建函式:
|
|
570
|
+
|
|
571
|
+
```typescript
|
|
572
|
+
private _onMouseMove = this.onMouseMove.bind(this);
|
|
573
|
+
private _onMouseUp = this.onMouseUp.bind(this);
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
## 常見問題
|
|
577
|
+
|
|
578
|
+
### Q: 如何避免與拖拽功能衝突?
|
|
579
|
+
|
|
580
|
+
A: `MarqueeSelector` 內建了拖拽元素檢測,會自動跳過 `draggable="true"` 的元素及其子元素。
|
|
581
|
+
|
|
582
|
+
### Q: 如何在點擊時不觸發框選?
|
|
583
|
+
|
|
584
|
+
A: 只有在滑鼠移動後才會觸發 `onSelectionStart`,單純點擊(無移動)不會觸發任何選取回調。
|
|
585
|
+
|
|
586
|
+
### Q: 如何清除選取?
|
|
587
|
+
|
|
588
|
+
A: 使用 `onClearClick` 回調,在使用者點擊空白處時清除選取狀態:
|
|
589
|
+
|
|
590
|
+
```typescript
|
|
591
|
+
onClearClick: (selectedElements) => {
|
|
592
|
+
selectedElements.forEach(el => el.classList.remove('selected'));
|
|
593
|
+
selectedIds.value = [];
|
|
594
|
+
}
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
### Q: 是否支援觸控裝置?
|
|
598
|
+
|
|
599
|
+
A: 目前僅支援滑鼠事件。觸控支援需要額外實作 `touchstart`、`touchmove`、`touchend` 事件。
|
|
600
|
+
|
|
601
|
+
### Q: 如何限制框選範圍?
|
|
602
|
+
|
|
603
|
+
A: 通過 `container` 選項限制框選區域,框選框和碰撞檢測都相對於該容器。
|
|
604
|
+
|
|
605
|
+
### Q: 可以同時選取多組不同的元素嗎?
|
|
606
|
+
|
|
607
|
+
A: 可以。使用多次 `addTarget()` 添加不同的目標配置,每個配置都有獨立的選取狀態和回調。
|
|
608
|
+
|
|
609
|
+
## 授權
|
|
610
|
+
|
|
611
|
+
請依照專案授權使用。
|
|
612
|
+
|
|
613
|
+
## 更新日誌
|
|
614
|
+
|
|
615
|
+
### v1.0.0 (2026-01-11)
|
|
616
|
+
- ✨ 初始版本
|
|
617
|
+
- ✨ 支援多目標配置
|
|
618
|
+
- ✨ 內建拖拽元素保護
|
|
619
|
+
- ✨ 完整生命週期回調
|
|
620
|
+
- ✨ WeakMap 快取優化
|
|
621
|
+
- ✨ CSS 變數支援
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hardanonymous/marquee-selector",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A framework-agnostic marquee selection library with drag & text selection protection",
|
|
5
|
+
"author": "hardanonymous <hard25670559@gmail.com>",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/index.cjs",
|
|
11
|
+
"module": "./dist/index.mjs",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./dist/index.mjs",
|
|
16
|
+
"require": "./dist/index.cjs",
|
|
17
|
+
"types": "./dist/index.d.ts"
|
|
18
|
+
},
|
|
19
|
+
"./style.css": "./dist/marquee-selector.css"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"docs",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "rollup -c",
|
|
29
|
+
"dev": "rollup -c -w",
|
|
30
|
+
"prepublishOnly": "npm run build"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"marquee",
|
|
34
|
+
"selection",
|
|
35
|
+
"drag-selection",
|
|
36
|
+
"box-selection",
|
|
37
|
+
"marquee-selection",
|
|
38
|
+
"typescript",
|
|
39
|
+
"framework-agnostic",
|
|
40
|
+
"vue",
|
|
41
|
+
"react",
|
|
42
|
+
"vanilla-js"
|
|
43
|
+
],
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "https://github.com/hardanonymous/marquee-selector.git"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/hardanonymous/marquee-selector/issues"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/hardanonymous/marquee-selector#readme",
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
55
|
+
"@rollup/plugin-typescript": "^11.1.5",
|
|
56
|
+
"cssnano": "^7.1.2",
|
|
57
|
+
"cssnano-preset-default": "^7.0.10",
|
|
58
|
+
"rollup": "^4.9.0",
|
|
59
|
+
"rollup-plugin-postcss": "^4.0.2",
|
|
60
|
+
"tslib": "^2.6.2",
|
|
61
|
+
"typescript": "^5.3.3"
|
|
62
|
+
}
|
|
63
|
+
}
|