├── README.md ├── script.js ├── style.css ├── index.html └── AreaSelector.js /README.md: -------------------------------------------------------------------------------- 1 | # area-selector 2 | 3 | **Online demo: https://codesteppe.github.io/area-selector/** -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | const areaSelector = new AreaSelector({ 2 | element: document.querySelector('#grid'), 3 | selectableTargetSelector: '.item', 4 | datasetKeyForSelection: 'id', 5 | onSelectionChange: (selectedIds) => { 6 | console.log('selectedIds', selectedIds); 7 | } 8 | }) -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; 6 | } 7 | 8 | body { 9 | width: 100vw; 10 | height: 100vh; 11 | overflow: hidden; 12 | display: grid; 13 | place-items: center; 14 | } 15 | 16 | #grid { 17 | width: 80vw; 18 | height: 80vh; 19 | display: grid; 20 | grid-template-columns: repeat(10, 1fr); 21 | gap: 5vh; 22 | padding: 5vh; 23 | border: 8px solid #878787; 24 | overflow: auto; 25 | } 26 | 27 | .item { 28 | width: 18vh; 29 | height: 18vh; 30 | display: grid; 31 | place-items: center; 32 | font-size: 80px; 33 | font-weight: bold; 34 | border: 2px solid #878787; 35 | user-select: none; 36 | } 37 | 38 | .item[data-selected="true"] { 39 | background-color: rgb(174, 229, 255); 40 | } 41 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Area Selector 9 | 10 | 11 | 12 | 13 | 14 |
15 |
1
16 |
2
17 |
3
18 |
4
19 |
5
20 |
6
21 |
7
22 |
8
23 |
9
24 |
10
25 |
11
26 |
12
27 |
13
28 |
14
29 |
15
30 |
16
31 |
17
32 |
18
33 |
19
34 |
20
35 |
21
36 |
22
37 |
23
38 |
24
39 |
25
40 |
26
41 |
27
42 |
28
43 |
29
44 |
30
45 |
31
46 |
32
47 |
33
48 |
34
49 |
35
50 |
36
51 |
37
52 |
38
53 |
39
54 |
40
55 |
41
56 |
42
57 |
43
58 |
44
59 |
45
60 |
46
61 |
47
62 |
48
63 |
49
64 |
50
65 |
51
66 |
52
67 |
53
68 |
54
69 |
55
70 |
56
71 |
57
72 |
58
73 |
59
74 |
60
75 |
61
76 |
62
77 |
63
78 |
64
79 |
65
80 |
66
81 |
67
82 |
68
83 |
69
84 |
70
85 |
71
86 |
72
87 |
73
88 |
74
89 |
75
90 |
76
91 |
77
92 |
78
93 |
79
94 |
80
95 |
81
96 |
82
97 |
83
98 |
84
99 |
85
100 |
86
101 |
87
102 |
88
103 |
89
104 |
90
105 |
91
106 |
92
107 |
93
108 |
94
109 |
95
110 |
96
111 |
97
112 |
98
113 |
99
114 |
100
115 |
116 | 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /AreaSelector.js: -------------------------------------------------------------------------------- 1 | class AreaSelector { 2 | constructor({ 3 | element, 4 | selectableTargetSelector, 5 | datasetKeyForSelection, 6 | onSelectionChange 7 | }) { 8 | this.element = element; 9 | this.selectableTargetSelector = selectableTargetSelector; 10 | this.datasetKeyForSelection = datasetKeyForSelection; 11 | this.onSelectionChange = onSelectionChange; 12 | this.selectedIds = []; 13 | this.#createSelectArea(); 14 | this.#handleMouseDown(); 15 | this.#handleMouseUp(); 16 | } 17 | 18 | // private properties 19 | #area; 20 | #startPoint; 21 | #endPoint; 22 | #mouseMoveHandler; 23 | #ctrlKeyPressed; 24 | #tempSelectedIds = []; // 缓存每次框选选中的元素,避免直接修改 this.selectedIds; 25 | #unselectedIds = []; // 需要被反选的元素 26 | 27 | // private methods 28 | #createSelectArea = () => { 29 | const area = document.createElement('div'); 30 | this.element.style.position = 'relative'; 31 | area.style.position = 'absolute'; 32 | area.style.border = '1px solid rgb(0,119,255)'; 33 | area.style.background = 'rgba(0,119,255,0.2)'; 34 | this.element.appendChild(area); 35 | this.#area = area; 36 | } 37 | 38 | #twoRectsHaveIntersection = (rect1, rect2) => { 39 | const left1 = rect1.left; 40 | const left2 = rect2.left; 41 | const right1 = rect1.left + rect1.width; 42 | const right2 = rect2.left + rect2.width; 43 | 44 | const top1 = rect1.top; 45 | const top2 = rect2.top; 46 | const bottom1 = rect1.top + rect1.height; 47 | const bottom2 = rect2.top + rect2.height; 48 | 49 | const width1 = rect1.width; 50 | const width2 = rect2.width; 51 | const height1 = rect1.height; 52 | const height2 = rect2.height; 53 | 54 | const noIntersection = left2 > right1 || left1 > right2 || bottom1 < top2 || bottom2 < top1 || width1 <= 0 || width2 <= 0 || height1 <= 0 || height2 <= 0; 55 | 56 | return !noIntersection; 57 | } 58 | 59 | #selectItems = () => { 60 | const areaRect = this.#area.getBoundingClientRect(); 61 | const items = this.element.querySelectorAll(this.selectableTargetSelector); 62 | for (const item of items) { 63 | const itemRect = item.getBoundingClientRect(); 64 | const hasIntersection = this.#twoRectsHaveIntersection(areaRect, itemRect); 65 | const itemId = item.dataset[this.datasetKeyForSelection]; 66 | const index = this.selectedIds.indexOf(itemId); 67 | const tempIndex = this.#tempSelectedIds.indexOf(itemId); 68 | let selected; 69 | 70 | if (this.#unselectedIds.includes(itemId)) { 71 | selected = false; 72 | } else { 73 | if (this.#ctrlKeyPressed) { 74 | if (index >= 0) { 75 | if (hasIntersection) { 76 | // 已经选中的元素如果再被框住则反选 77 | selected = false; 78 | this.#unselectedIds.push(itemId); 79 | } else { 80 | // 已选中的元素保持选中状态 81 | selected = true; 82 | } 83 | } else { 84 | // 本次是否选中通过是否和 areaRect 有重叠判断 85 | selected = hasIntersection; 86 | } 87 | } else { 88 | selected = hasIntersection; 89 | } 90 | } 91 | 92 | item.dataset.selected = selected; 93 | if (selected) { 94 | if (tempIndex === -1) { 95 | this.#tempSelectedIds.push(itemId); 96 | } 97 | } else { 98 | if (tempIndex >= 0) { 99 | this.#tempSelectedIds.splice(tempIndex, 1); 100 | } 101 | if (index >= 0) { 102 | this.selectedIds.splice(index, 1); 103 | } 104 | } 105 | } 106 | } 107 | 108 | #updateArea = () => { 109 | const top = Math.min(this.#startPoint.y, this.#endPoint.y); 110 | const left = Math.min(this.#startPoint.x, this.#endPoint.x); 111 | const width = Math.abs(this.#startPoint.x - this.#endPoint.x); 112 | const height = Math.abs(this.#startPoint.y - this.#endPoint.y); 113 | this.#area.style.top = top + 'px'; 114 | this.#area.style.left = left + 'px'; 115 | this.#area.style.width = width + 'px'; 116 | this.#area.style.height = height + 'px'; 117 | this.#selectItems(); 118 | } 119 | 120 | #hideArea = () => { 121 | this.#area.style.display = 'none'; 122 | } 123 | 124 | #showArea = () => { 125 | this.#area.style.display = 'block'; 126 | } 127 | 128 | #getRelativePositionInElement = (clientX, clientY) => { 129 | const rect = this.element.getBoundingClientRect(); 130 | const { left, top } = rect; 131 | const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = this.element; 132 | let x = clientX - left + scrollLeft; 133 | let y = clientY - top + scrollTop; 134 | if (x < 0) { 135 | x = 0; 136 | } else if (x > scrollWidth) { 137 | x = scrollWidth; 138 | } 139 | 140 | if (y < 0) { 141 | y = 0; 142 | } else if (y > scrollHeight) { 143 | y = scrollHeight; 144 | } 145 | 146 | return { x: Math.round(x), y: Math.round(y) }; 147 | } 148 | 149 | #handleMouseDown = () => { 150 | this.element.addEventListener('mousedown', e => { 151 | const { clientX, clientY, ctrlKey } = e; 152 | console.log('ctrlKey', ctrlKey); 153 | this.#ctrlKeyPressed = ctrlKey; 154 | this.#tempSelectedIds = []; 155 | this.#unselectedIds = []; 156 | this.#startPoint = this.#getRelativePositionInElement(clientX, clientY); 157 | // console.log('start point', this.#startPoint); 158 | this.#endPoint = this.#startPoint; 159 | this.#updateArea(); 160 | this.#showArea(); 161 | this.#handleMouseMove(); 162 | }); 163 | } 164 | 165 | #handleMouseMove = () => { 166 | this.#mouseMoveHandler = (e) => { 167 | const { clientX, clientY } = e; 168 | this.#endPoint = this.#getRelativePositionInElement(clientX, clientY); 169 | // console.log('end point', this.#endPoint); 170 | this.#updateArea(); 171 | this.#scrollOnDrag(clientX, clientY); 172 | } 173 | window.addEventListener('mousemove', this.#mouseMoveHandler); 174 | } 175 | 176 | #handleMouseUp = () => { 177 | window.addEventListener('mouseup', e => { 178 | window.removeEventListener('mousemove', this.#mouseMoveHandler); 179 | this.#hideArea(); 180 | // mouseup 的时候merge上次和本次的选中结果 181 | const updated = Array.from(new Set([...this.selectedIds, ...this.#tempSelectedIds])); 182 | this.selectedIds = updated; 183 | this.onSelectionChange(updated); 184 | }); 185 | } 186 | 187 | #scrollOnDrag = (mouseX, mouseY) => { 188 | const { x, y, width, height } = this.element.getBoundingClientRect(); 189 | 190 | let scrollX, scrollY; 191 | 192 | if (mouseX < x) { 193 | scrollX = mouseX - x; 194 | } else if (mouseX > (x + width)) { 195 | scrollX = mouseX - (x + width); 196 | } 197 | 198 | if (mouseY < y) { 199 | scrollY = mouseY - y; 200 | } else if (mouseY > (y + height)) { 201 | scrollY = mouseY - (y + height); 202 | } 203 | 204 | if (scrollX || scrollY) { 205 | this.element.scrollBy({ 206 | left: scrollX, 207 | top: scrollY, 208 | behavior: 'auto' 209 | }); 210 | } 211 | } 212 | } --------------------------------------------------------------------------------