@barefootjs/xyflow 0.1.0
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/README.md +117 -0
- package/dist/classes.d.ts +31 -0
- package/dist/classes.d.ts.map +1 -0
- package/dist/compat.d.ts +67 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/connection.d.ts +20 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/edge-path.d.ts +14 -0
- package/dist/edge-path.d.ts.map +1 -0
- package/dist/flow-subsystems.d.ts +28 -0
- package/dist/flow-subsystems.d.ts.map +1 -0
- package/dist/hooks.d.ts +34 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6572 -0
- package/dist/node-resizer.d.ts +52 -0
- package/dist/node-resizer.d.ts.map +1 -0
- package/dist/node-type-dispatch.d.ts +34 -0
- package/dist/node-type-dispatch.d.ts.map +1 -0
- package/dist/selection.d.ts +32 -0
- package/dist/selection.d.ts.map +1 -0
- package/dist/store.d.ts +8 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +214 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/xyflow.browser.min.js +95 -0
- package/dist/xyflow.browser.min.js.map +129 -0
- package/package.json +58 -0
- package/src/__tests__/clamp-drag-position.test.ts +111 -0
- package/src/__tests__/compat.test.ts +157 -0
- package/src/__tests__/host-element-store.test.ts +33 -0
- package/src/__tests__/jsx-smoke.test.ts +33 -0
- package/src/__tests__/jsx-smoke.tsx +23 -0
- package/src/__tests__/node-type-dispatch.test.ts +104 -0
- package/src/__tests__/store.test.ts +399 -0
- package/src/__tests__/tsconfig.json +23 -0
- package/src/classes.ts +41 -0
- package/src/compat.ts +237 -0
- package/src/connection.ts +459 -0
- package/src/constants.ts +8 -0
- package/src/context.ts +8 -0
- package/src/edge-path.ts +89 -0
- package/src/flow-subsystems.ts +506 -0
- package/src/hooks.ts +72 -0
- package/src/index.ts +134 -0
- package/src/node-resizer.ts +276 -0
- package/src/node-type-dispatch.ts +46 -0
- package/src/selection.ts +407 -0
- package/src/store.ts +526 -0
- package/src/types.ts +329 -0
- package/src/utils.ts +13 -0
package/src/selection.ts
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { onCleanup, untrack } from '@barefootjs/client'
|
|
2
|
+
import type { NodeBase, EdgeBase, InternalNodeBase, Transform } from '@xyflow/system'
|
|
3
|
+
import type { FlowStore, InternalFlowStore, SelectionMode } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Set up keyboard handlers for the flow container.
|
|
7
|
+
* Needs InternalFlowStore for setMultiSelectionActive (Shift key).
|
|
8
|
+
*/
|
|
9
|
+
export function setupKeyboardHandlers<
|
|
10
|
+
NodeType extends NodeBase = NodeBase,
|
|
11
|
+
EdgeType extends EdgeBase = EdgeBase,
|
|
12
|
+
>(
|
|
13
|
+
store: InternalFlowStore<NodeType, EdgeType>,
|
|
14
|
+
container: HTMLElement,
|
|
15
|
+
): void {
|
|
16
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
17
|
+
const target = event.target as HTMLElement
|
|
18
|
+
if (
|
|
19
|
+
target.tagName === 'INPUT' ||
|
|
20
|
+
target.tagName === 'TEXTAREA' ||
|
|
21
|
+
target.isContentEditable
|
|
22
|
+
) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Delete key handling (configurable, null disables)
|
|
27
|
+
const deleteKeys = store.deleteKeyCode
|
|
28
|
+
if (deleteKeys && deleteKeys.includes(event.key)) {
|
|
29
|
+
if (!untrack(store.nodesDraggable)) return
|
|
30
|
+
const selectedNodes = untrack(store.nodes).filter((n) => n.selected)
|
|
31
|
+
const selectedEdges = untrack(store.edges).filter((e) => e.selected)
|
|
32
|
+
|
|
33
|
+
if (selectedNodes.length > 0 || selectedEdges.length > 0) {
|
|
34
|
+
// Fire deletion callbacks before deleting
|
|
35
|
+
if (selectedNodes.length > 0 && store.onNodesDelete) {
|
|
36
|
+
store.onNodesDelete(selectedNodes)
|
|
37
|
+
}
|
|
38
|
+
if (selectedEdges.length > 0 && store.onEdgesDelete) {
|
|
39
|
+
store.onEdgesDelete(selectedEdges)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
store.deleteElements({
|
|
43
|
+
nodes: selectedNodes,
|
|
44
|
+
edges: selectedEdges,
|
|
45
|
+
})
|
|
46
|
+
event.preventDefault()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (event.key === 'Escape') {
|
|
51
|
+
store.unselectNodesAndEdges()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Selection key handling (configurable)
|
|
55
|
+
const selKey = store.selectionKeyCode
|
|
56
|
+
if (selKey && event.key === selKey) {
|
|
57
|
+
store.setMultiSelectionActive(true)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleKeyUp(event: KeyboardEvent) {
|
|
62
|
+
const selKey = store.selectionKeyCode
|
|
63
|
+
if (selKey && event.key === selKey) {
|
|
64
|
+
store.setMultiSelectionActive(false)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
container.setAttribute('tabindex', '0')
|
|
69
|
+
container.style.outline = 'none'
|
|
70
|
+
container.addEventListener('keydown', handleKeyDown)
|
|
71
|
+
container.addEventListener('keyup', handleKeyUp)
|
|
72
|
+
|
|
73
|
+
onCleanup(() => {
|
|
74
|
+
container.removeEventListener('keydown', handleKeyDown)
|
|
75
|
+
container.removeEventListener('keyup', handleKeyUp)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Set up click-to-select on node elements.
|
|
81
|
+
* Called from node-wrapper when creating each node.
|
|
82
|
+
*/
|
|
83
|
+
export function setupNodeSelection<NodeType extends NodeBase>(
|
|
84
|
+
nodeElement: HTMLElement,
|
|
85
|
+
nodeId: string,
|
|
86
|
+
store: FlowStore<NodeType>,
|
|
87
|
+
): void {
|
|
88
|
+
// Use mousedown instead of click — D3 zoom's mousedown handler on the
|
|
89
|
+
// container calls stopImmediatePropagation, which prevents the native
|
|
90
|
+
// click event from reaching the node element.
|
|
91
|
+
nodeElement.addEventListener('mousedown', (event) => {
|
|
92
|
+
if (event.button !== 0) return
|
|
93
|
+
|
|
94
|
+
const multiSelect = untrack(store.multiSelectionActive) || event.shiftKey
|
|
95
|
+
|
|
96
|
+
if (!multiSelect) {
|
|
97
|
+
// Deselect all, then select this one
|
|
98
|
+
store.unselectNodesAndEdges()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Focus the container so keyboard events (Delete) work
|
|
102
|
+
const container = untrack(store.domNode)
|
|
103
|
+
if (container) container.focus()
|
|
104
|
+
|
|
105
|
+
// Toggle this node's selection
|
|
106
|
+
store.setNodes((prev) =>
|
|
107
|
+
prev.map((n) =>
|
|
108
|
+
n.id === nodeId
|
|
109
|
+
? { ...n, selected: multiSelect ? !n.selected : true }
|
|
110
|
+
: n,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Simple rect type for selection calculations.
|
|
118
|
+
*/
|
|
119
|
+
type SelectionRect = { x: number; y: number; width: number; height: number }
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Find nodes whose absolute positions overlap a screen-space rectangle.
|
|
123
|
+
*
|
|
124
|
+
* Unlike @xyflow/system's getNodesInside, this does NOT require handleBounds
|
|
125
|
+
* to be set — it works directly with positionAbsolute and measured dimensions.
|
|
126
|
+
* This avoids the forceInitialRender fallback that returns ALL nodes.
|
|
127
|
+
*/
|
|
128
|
+
function findNodesInRect<NodeType extends NodeBase>(
|
|
129
|
+
nodeLookup: Map<string, InternalNodeBase<NodeType>>,
|
|
130
|
+
rect: SelectionRect,
|
|
131
|
+
[tx, ty, tScale]: Transform,
|
|
132
|
+
partially: boolean,
|
|
133
|
+
): InternalNodeBase<NodeType>[] {
|
|
134
|
+
// Convert the screen-space rect to flow-space (undo viewport transform)
|
|
135
|
+
const flowRect = {
|
|
136
|
+
x: (rect.x - tx) / tScale,
|
|
137
|
+
y: (rect.y - ty) / tScale,
|
|
138
|
+
width: rect.width / tScale,
|
|
139
|
+
height: rect.height / tScale,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const result: InternalNodeBase<NodeType>[] = []
|
|
143
|
+
|
|
144
|
+
for (const node of nodeLookup.values()) {
|
|
145
|
+
if (node.hidden) continue
|
|
146
|
+
|
|
147
|
+
const nodeW = node.measured.width ?? 0
|
|
148
|
+
const nodeH = node.measured.height ?? 0
|
|
149
|
+
if (nodeW === 0 && nodeH === 0) continue
|
|
150
|
+
|
|
151
|
+
const pos = node.internals.positionAbsolute
|
|
152
|
+
|
|
153
|
+
// Calculate overlap between the flow-space selection rect and node rect
|
|
154
|
+
const overlapX = Math.max(0,
|
|
155
|
+
Math.min(flowRect.x + flowRect.width, pos.x + nodeW) -
|
|
156
|
+
Math.max(flowRect.x, pos.x),
|
|
157
|
+
)
|
|
158
|
+
const overlapY = Math.max(0,
|
|
159
|
+
Math.min(flowRect.y + flowRect.height, pos.y + nodeH) -
|
|
160
|
+
Math.max(flowRect.y, pos.y),
|
|
161
|
+
)
|
|
162
|
+
const overlapArea = overlapX * overlapY
|
|
163
|
+
const nodeArea = nodeW * nodeH
|
|
164
|
+
|
|
165
|
+
if (partially) {
|
|
166
|
+
// Partial mode: any overlap counts
|
|
167
|
+
if (overlapArea > 0) result.push(node)
|
|
168
|
+
} else {
|
|
169
|
+
// Full mode: node must be fully contained
|
|
170
|
+
if (overlapArea >= nodeArea) result.push(node)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return result
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Compute screen-space bounding box around the given internal nodes.
|
|
179
|
+
*/
|
|
180
|
+
function getSelectedNodesBBox<NodeType extends NodeBase>(
|
|
181
|
+
nodes: InternalNodeBase<NodeType>[],
|
|
182
|
+
[tx, ty, tScale]: Transform,
|
|
183
|
+
): SelectionRect | null {
|
|
184
|
+
if (nodes.length === 0) return null
|
|
185
|
+
|
|
186
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
|
187
|
+
|
|
188
|
+
for (const node of nodes) {
|
|
189
|
+
const pos = node.internals.positionAbsolute
|
|
190
|
+
const nw = node.measured.width ?? 0
|
|
191
|
+
const nh = node.measured.height ?? 0
|
|
192
|
+
// Convert flow-space to screen-space
|
|
193
|
+
const sx = pos.x * tScale + tx
|
|
194
|
+
const sy = pos.y * tScale + ty
|
|
195
|
+
const sw = nw * tScale
|
|
196
|
+
const sh = nh * tScale
|
|
197
|
+
minX = Math.min(minX, sx)
|
|
198
|
+
minY = Math.min(minY, sy)
|
|
199
|
+
maxX = Math.max(maxX, sx + sw)
|
|
200
|
+
maxY = Math.max(maxY, sy + sh)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
x: minX,
|
|
205
|
+
y: minY,
|
|
206
|
+
width: maxX - minX,
|
|
207
|
+
height: maxY - minY,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Options for the selection rectangle behavior.
|
|
213
|
+
*/
|
|
214
|
+
export type SelectionRectOptions = {
|
|
215
|
+
/** When true, drag on pane starts selection without Shift key */
|
|
216
|
+
selectionOnDrag?: boolean
|
|
217
|
+
/** 'partial' selects overlapping nodes; 'full' requires full containment */
|
|
218
|
+
selectionMode?: SelectionMode
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Set up selection rectangle (rubber-band / lasso) on the flow container.
|
|
223
|
+
*
|
|
224
|
+
* The rectangle is drawn when:
|
|
225
|
+
* - Shift+drag on empty pane, OR
|
|
226
|
+
* - Drag on empty pane when `selectionOnDrag` is true
|
|
227
|
+
*
|
|
228
|
+
* Nodes inside the rectangle are selected on mouse up.
|
|
229
|
+
*/
|
|
230
|
+
export function setupSelectionRectangle<
|
|
231
|
+
NodeType extends NodeBase = NodeBase,
|
|
232
|
+
EdgeType extends EdgeBase = EdgeBase,
|
|
233
|
+
>(
|
|
234
|
+
store: InternalFlowStore<NodeType, EdgeType>,
|
|
235
|
+
container: HTMLElement,
|
|
236
|
+
options: SelectionRectOptions = {},
|
|
237
|
+
): void {
|
|
238
|
+
const selectionOnDrag = options.selectionOnDrag ?? false
|
|
239
|
+
const selectionMode: SelectionMode = options.selectionMode ?? 'partial'
|
|
240
|
+
|
|
241
|
+
let selectionRect: HTMLDivElement | null = null
|
|
242
|
+
let isSelecting = false
|
|
243
|
+
let startX = 0
|
|
244
|
+
let startY = 0
|
|
245
|
+
|
|
246
|
+
function handleMouseDown(event: MouseEvent) {
|
|
247
|
+
if (event.button !== 0) return
|
|
248
|
+
|
|
249
|
+
// Remove any lingering selection bounding box from previous selection
|
|
250
|
+
if (selectionRect) {
|
|
251
|
+
selectionRect.remove()
|
|
252
|
+
selectionRect = null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Only start selection on the container or viewport (empty pane),
|
|
256
|
+
// not on nodes, handles, controls, etc.
|
|
257
|
+
const target = event.target as HTMLElement
|
|
258
|
+
const isPane =
|
|
259
|
+
target === container ||
|
|
260
|
+
target.classList.contains('bf-flow__viewport') ||
|
|
261
|
+
target.classList.contains('bf-flow__nodes') ||
|
|
262
|
+
target.classList.contains('bf-flow__edges')
|
|
263
|
+
|
|
264
|
+
if (!isPane) return
|
|
265
|
+
|
|
266
|
+
// Determine if selection should activate:
|
|
267
|
+
// - Shift+drag always triggers selection
|
|
268
|
+
// - Plain drag triggers selection only if selectionOnDrag is true
|
|
269
|
+
const shiftHeld = event.shiftKey
|
|
270
|
+
if (!shiftHeld && !selectionOnDrag) return
|
|
271
|
+
|
|
272
|
+
// Stop propagation so D3 pan/zoom doesn't compete with selection drag
|
|
273
|
+
event.stopPropagation()
|
|
274
|
+
event.preventDefault()
|
|
275
|
+
|
|
276
|
+
isSelecting = true
|
|
277
|
+
startX = event.clientX
|
|
278
|
+
startY = event.clientY
|
|
279
|
+
|
|
280
|
+
// Create the selection rectangle element
|
|
281
|
+
selectionRect = document.createElement('div')
|
|
282
|
+
selectionRect.className = 'bf-flow__selection'
|
|
283
|
+
selectionRect.style.position = 'absolute'
|
|
284
|
+
selectionRect.style.pointerEvents = 'none'
|
|
285
|
+
selectionRect.style.left = '0'
|
|
286
|
+
selectionRect.style.top = '0'
|
|
287
|
+
selectionRect.style.width = '0'
|
|
288
|
+
selectionRect.style.height = '0'
|
|
289
|
+
selectionRect.style.zIndex = '5'
|
|
290
|
+
container.appendChild(selectionRect)
|
|
291
|
+
|
|
292
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
293
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function handleMouseMove(event: MouseEvent) {
|
|
297
|
+
if (!isSelecting || !selectionRect) return
|
|
298
|
+
|
|
299
|
+
const containerRect = container.getBoundingClientRect()
|
|
300
|
+
const currentX = event.clientX
|
|
301
|
+
const currentY = event.clientY
|
|
302
|
+
|
|
303
|
+
// Calculate rectangle bounds relative to the container
|
|
304
|
+
const left = Math.min(startX, currentX) - containerRect.left
|
|
305
|
+
const top = Math.min(startY, currentY) - containerRect.top
|
|
306
|
+
const width = Math.abs(currentX - startX)
|
|
307
|
+
const height = Math.abs(currentY - startY)
|
|
308
|
+
|
|
309
|
+
selectionRect.style.left = `${left}px`
|
|
310
|
+
selectionRect.style.top = `${top}px`
|
|
311
|
+
selectionRect.style.width = `${width}px`
|
|
312
|
+
selectionRect.style.height = `${height}px`
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function handleMouseUp(event: MouseEvent) {
|
|
316
|
+
if (!isSelecting) return
|
|
317
|
+
|
|
318
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
319
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
320
|
+
|
|
321
|
+
// Determine which nodes are inside the selection rectangle
|
|
322
|
+
const containerRect = container.getBoundingClientRect()
|
|
323
|
+
const currentX = event.clientX
|
|
324
|
+
const currentY = event.clientY
|
|
325
|
+
|
|
326
|
+
const left = Math.min(startX, currentX) - containerRect.left
|
|
327
|
+
const top = Math.min(startY, currentY) - containerRect.top
|
|
328
|
+
const width = Math.abs(currentX - startX)
|
|
329
|
+
const height = Math.abs(currentY - startY)
|
|
330
|
+
|
|
331
|
+
// Only process selection if the rectangle has meaningful size
|
|
332
|
+
// (avoid accidental clicks registering as selection)
|
|
333
|
+
if (width > 5 || height > 5) {
|
|
334
|
+
const transform = store.getTransform()
|
|
335
|
+
const nodeLookup = untrack(store.nodeLookup)
|
|
336
|
+
const partially = selectionMode === 'partial'
|
|
337
|
+
|
|
338
|
+
const nodesInside = findNodesInRect(
|
|
339
|
+
nodeLookup,
|
|
340
|
+
{ x: left, y: top, width, height },
|
|
341
|
+
transform,
|
|
342
|
+
partially,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
const selectedIds = new Set(nodesInside.map((n) => n.id))
|
|
346
|
+
|
|
347
|
+
if (selectedIds.size > 0) {
|
|
348
|
+
store.setNodes((prev) =>
|
|
349
|
+
prev.map((n) =>
|
|
350
|
+
selectedIds.has(n.id) ? { ...n, selected: true } : { ...n, selected: false },
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
// Reposition selection rect as bounding box around selected nodes
|
|
355
|
+
if (selectionRect) {
|
|
356
|
+
const transform = store.getTransform()
|
|
357
|
+
const bbox = getSelectedNodesBBox(nodesInside, transform)
|
|
358
|
+
if (bbox) {
|
|
359
|
+
selectionRect.style.left = `${bbox.x}px`
|
|
360
|
+
selectionRect.style.top = `${bbox.y}px`
|
|
361
|
+
selectionRect.style.width = `${bbox.width}px`
|
|
362
|
+
selectionRect.style.height = `${bbox.height}px`
|
|
363
|
+
selectionRect.classList.add('bf-flow__selection--active')
|
|
364
|
+
// Focus container so keyboard Delete/Escape works immediately
|
|
365
|
+
container.focus()
|
|
366
|
+
} else {
|
|
367
|
+
selectionRect.remove()
|
|
368
|
+
selectionRect = null
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
store.unselectNodesAndEdges()
|
|
373
|
+
if (selectionRect) { selectionRect.remove(); selectionRect = null }
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
// Small drag = click on empty pane, deselect all
|
|
377
|
+
store.unselectNodesAndEdges()
|
|
378
|
+
if (selectionRect) { selectionRect.remove(); selectionRect = null }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
isSelecting = false
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Clean up selection rect when selected nodes are deleted
|
|
385
|
+
function handleSelectionKeyDown(event: KeyboardEvent) {
|
|
386
|
+
if (!selectionRect) return
|
|
387
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
388
|
+
selectionRect.remove()
|
|
389
|
+
selectionRect = null
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Use capture phase so we can intercept before D3 zoom when needed
|
|
394
|
+
container.addEventListener('mousedown', handleMouseDown, true)
|
|
395
|
+
container.addEventListener('keydown', handleSelectionKeyDown)
|
|
396
|
+
|
|
397
|
+
onCleanup(() => {
|
|
398
|
+
container.removeEventListener('mousedown', handleMouseDown, true)
|
|
399
|
+
container.removeEventListener('keydown', handleSelectionKeyDown)
|
|
400
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
401
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
402
|
+
if (selectionRect) {
|
|
403
|
+
selectionRect.remove()
|
|
404
|
+
selectionRect = null
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
}
|