├── 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 | }
--------------------------------------------------------------------------------