jellies-draw 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jellies-draw",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A drawer for jellies",
5
5
  "private": false,
6
6
  "main": "./src/index.js",
@@ -42,6 +42,7 @@ export default {
42
42
  window.addEventListener('pointerup', this.handleGlobalPointerUp, { once: true });
43
43
  }
44
44
  });
45
+ this.stage.on('mousemove', this._handleMouseMove.bind(this));
45
46
  },
46
47
  generateEventHandlers() {
47
48
  this.resizeHandler = this._handleResize.bind(this);
@@ -193,6 +194,44 @@ export default {
193
194
  return;
194
195
  }
195
196
  Transformer.selectNode(targetNode, metaPressed);
197
+ },
198
+ _handleMouseMove(e) {
199
+ if (Properties.isUsingDrawingTool || Properties.isUsingText) {
200
+ return;
201
+ }
202
+
203
+ const target = e.target;
204
+
205
+ if (target === this.stage) {
206
+ this.stage.container().style.cursor = 'default';
207
+ return;
208
+ }
209
+
210
+ if (target.hasName('node')) {
211
+ const mousePos = this.stage.getPointerPosition();
212
+ const canInteract = this._canInteractWithNode(target, mousePos);
213
+
214
+ this.stage.container().style.cursor = canInteract ? 'move' : 'default';
215
+
216
+ if (!canInteract) {
217
+ target.draggable(false);
218
+ } else if (!Properties.isUsingDrawingTool) {
219
+ target.draggable(true);
220
+ }
221
+ }
222
+ },
223
+ _canInteractWithNode(node, mousePos) {
224
+ const selectedNodes = Transformer._nodes();
225
+ if (selectedNodes.includes(node)) {
226
+ return true;
227
+ }
228
+
229
+ if (!['Rect', 'Ellipse'].includes(node.className) ||
230
+ (node.attrs.fill && node.attrs.fill !== 'transparent')) {
231
+ return true;
232
+ }
233
+
234
+ return Transformer._isNearBorder(node, mousePos);
196
235
  }
197
236
  }
198
237
 
@@ -2,6 +2,8 @@ import Konva from 'konva'
2
2
  import Properties from '../properties'
3
3
  import Canvas from '../canvas'
4
4
  import Histories from '../histories'
5
+ import AngleSnap from './helper/angleSnap'
6
+
5
7
  export default {
6
8
  temporalShape: null,
7
9
  startX: 0,
@@ -24,9 +26,16 @@ export default {
24
26
  this.temporalShape = newArrow
25
27
  Canvas.layer.add(this.temporalShape)
26
28
  },
27
- change({ offsetX, offsetY }) {
29
+ change({ offsetX, offsetY, shiftKey }) {
28
30
  if (Canvas.isDrawing) {
29
- this.temporalShape.points([this.startX, this.startY, offsetX, offsetY]);
31
+ const endPoint = AngleSnap.getSnappedEndPoint(
32
+ this.startX,
33
+ this.startY,
34
+ offsetX,
35
+ offsetY,
36
+ shiftKey
37
+ );
38
+ this.temporalShape.points([this.startX, this.startY, endPoint.x, endPoint.y]);
30
39
  }
31
40
  },
32
41
  finish() {
@@ -2,6 +2,8 @@ import Konva from 'konva'
2
2
  import Properties from '../properties'
3
3
  import Canvas from '../canvas'
4
4
  import Histories from '../histories'
5
+ import ShapeConstraint from './helper/shapeConstraint'
6
+
5
7
  export default {
6
8
  temporalShape: null,
7
9
  startX: 0,
@@ -26,12 +28,19 @@ export default {
26
28
  this.temporalShape = newEllipse
27
29
  Canvas.layer.add(this.temporalShape)
28
30
  },
29
- change({ offsetX, offsetY }) {
31
+ change({ offsetX, offsetY, shiftKey }) {
30
32
  if (Canvas.isDrawing) {
33
+ const { width, height } = ShapeConstraint.getConstrainedSize(
34
+ this.startX,
35
+ this.startY,
36
+ offsetX,
37
+ offsetY,
38
+ shiftKey
39
+ );
31
40
  this.temporalShape.x((offsetX + this.startX) / 2);
32
41
  this.temporalShape.y((offsetY + this.startY) / 2);
33
- this.temporalShape.radiusX(Math.abs(offsetX - this.startX) / 2);
34
- this.temporalShape.radiusY(Math.abs(offsetY - this.startY) / 2);
42
+ this.temporalShape.radiusX(Math.abs(width) / 2);
43
+ this.temporalShape.radiusY(Math.abs(height) / 2);
35
44
  }
36
45
  },
37
46
  finish() {
@@ -26,8 +26,8 @@ export default {
26
26
  change({ offsetX, offsetY }) {
27
27
  if (!Canvas.isDrawing) return
28
28
 
29
+ this._interpolateAndCheck(this.lastX, this.lastY, offsetX, offsetY)
29
30
  this._updateEraserPath(offsetX, offsetY)
30
- this._checkAndMarkIntersections(offsetX, offsetY)
31
31
 
32
32
  this.lastX = offsetX
33
33
  this.lastY = offsetY
@@ -60,11 +60,21 @@ export default {
60
60
  },
61
61
 
62
62
  _checkAndMarkIntersections(x, y) {
63
+ const eraserRadius = Properties.strokeWidth
64
+
63
65
  Canvas.stage.find('.node').forEach(node => {
64
66
  if (node === this.temporalShape) return
65
67
 
66
- if (this._checkIntersection(node, { x, y })) {
67
- if (!this.erasedNodes.has(node)) {
68
+ const nodeRect = node.getClientRect()
69
+ const eraserRect = {
70
+ x: x - eraserRadius,
71
+ y: y - eraserRadius,
72
+ width: eraserRadius * 2,
73
+ height: eraserRadius * 2
74
+ }
75
+
76
+ if (this._checkRectOverlap(nodeRect, eraserRect)) {
77
+ if (this._checkIntersection(node, { x, y }) && !this.erasedNodes.has(node)) {
68
78
  this._markNodeForErasure(node)
69
79
  }
70
80
  }
@@ -92,24 +102,37 @@ export default {
92
102
  if (node.attrs.nodeType === 'pen') {
93
103
  return this._checkPenIntersection(node, point)
94
104
  }
95
- return this._checkDefaultIntersection(node)
105
+
106
+ switch (node.className) {
107
+ case 'Line':
108
+ case 'Arrow':
109
+ return this._checkLineIntersection(node, point)
110
+ case 'Ellipse':
111
+ return this._checkEllipseIntersection(node, point)
112
+ case 'Rect':
113
+ return this._checkRectIntersection(node, point)
114
+ default:
115
+ return this._checkDefaultIntersection(node)
116
+ }
96
117
  },
97
118
 
98
119
  _checkPenIntersection(node, point) {
99
120
  const localPoint = this._transformPointToLocal(node, point)
100
- const { points: pts, widths } = node.attrs
121
+ const { points, widths } = node.attrs
122
+ const eraserRadius = Properties.strokeWidth
101
123
 
102
- for (let i = 0; i < pts.length - 1; i++) {
124
+ for (let i = 0; i < points.length - 1; i++) {
103
125
  const distance = this._getDistanceToLineSegment(
104
126
  localPoint.x,
105
127
  localPoint.y,
106
- pts[i][0],
107
- pts[i][1],
108
- pts[i + 1][0],
109
- pts[i + 1][1]
128
+ points[i][0],
129
+ points[i][1],
130
+ points[i + 1][0],
131
+ points[i + 1][1]
110
132
  )
111
133
 
112
- if (distance < (widths[i] + Properties.strokeWidth) / 2) {
134
+ const combinedWidth = (widths[i] + eraserRadius) / 2
135
+ if (distance < combinedWidth) {
113
136
  return true
114
137
  }
115
138
  }
@@ -153,5 +176,102 @@ export default {
153
176
  const dx = x1 - x2
154
177
  const dy = y1 - y2
155
178
  return Math.sqrt(dx * dx + dy * dy)
179
+ },
180
+
181
+ _checkLineIntersection(node, point) {
182
+ const points = node.points()
183
+ const transform = node.getTransform()
184
+ const localPoint = transform.copy().invert().point(point)
185
+ const eraserRadius = Properties.strokeWidth
186
+
187
+ for (let i = 0; i < points.length - 2; i += 2) {
188
+ const distance = this._getDistanceToLineSegment(
189
+ localPoint.x,
190
+ localPoint.y,
191
+ points[i],
192
+ points[i + 1],
193
+ points[i + 2],
194
+ points[i + 3]
195
+ )
196
+
197
+ const combinedWidth = (node.strokeWidth() + eraserRadius) / 2
198
+ if (distance < combinedWidth) {
199
+ return true
200
+ }
201
+ }
202
+ return false
203
+ },
204
+
205
+ _checkEllipseIntersection(node, point) {
206
+ const transform = node.getTransform()
207
+ const localPoint = transform.copy().invert().point(point)
208
+
209
+ const radiusX = node.radiusX()
210
+ const radiusY = node.radiusY()
211
+
212
+ // Normalize point to unit circle
213
+ const normalizedX = localPoint.x / radiusX
214
+ const normalizedY = localPoint.y / radiusY
215
+
216
+ // Check if point is within ellipse border
217
+ const distanceFromCenter = Math.sqrt(
218
+ normalizedX * normalizedX + normalizedY * normalizedY
219
+ )
220
+
221
+ if (node.attrs.fill && node.attrs.fill !== 'transparent') {
222
+ // For filled ellipses, allow erasing anywhere inside
223
+ return distanceFromCenter <= 1
224
+ }
225
+ // For unfilled ellipses, only erase near the border
226
+ return Math.abs(distanceFromCenter - 1) < Properties.strokeWidth / Math.min(radiusX, radiusY)
227
+ },
228
+
229
+ _checkRectIntersection(node, point) {
230
+ const transform = node.getTransform()
231
+ const localPoint = transform.copy().invert().point(point)
232
+
233
+ const width = node.width()
234
+ const height = node.height()
235
+ const strokeWidth = Properties.strokeWidth
236
+
237
+ if (node.attrs.fill && node.attrs.fill !== 'transparent') {
238
+ // For filled rectangles, allow erasing anywhere inside
239
+ return localPoint.x >= -strokeWidth &&
240
+ localPoint.x <= width + strokeWidth &&
241
+ localPoint.y >= -strokeWidth &&
242
+ localPoint.y <= height + strokeWidth
243
+ }
244
+ // For unfilled rectangles, only erase near the border
245
+ return (Math.abs(localPoint.x) < strokeWidth ||
246
+ Math.abs(localPoint.x - width) < strokeWidth ||
247
+ Math.abs(localPoint.y) < strokeWidth ||
248
+ Math.abs(localPoint.y - height) < strokeWidth) &&
249
+ localPoint.x >= -strokeWidth &&
250
+ localPoint.x <= width + strokeWidth &&
251
+ localPoint.y >= -strokeWidth &&
252
+ localPoint.y <= height + strokeWidth
253
+ },
254
+
255
+ _interpolateAndCheck(x1, y1, x2, y2) {
256
+ const distance = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
257
+ const steps = Math.ceil(distance / (Properties.strokeWidth / 2))
258
+
259
+ if (steps > 1) {
260
+ for (let i = 1; i < steps; i++) {
261
+ const ratio = i / steps
262
+ const x = x1 + (x2 - x1) * ratio
263
+ const y = y1 + (y2 - y1) * ratio
264
+ this._checkAndMarkIntersections(x, y)
265
+ }
266
+ } else {
267
+ this._checkAndMarkIntersections(x2, y2)
268
+ }
269
+ },
270
+
271
+ _checkRectOverlap(rect1, rect2) {
272
+ return !(rect1.x > rect2.x + rect2.width ||
273
+ rect1.x + rect1.width < rect2.x ||
274
+ rect1.y > rect2.y + rect2.height ||
275
+ rect1.y + rect1.height < rect2.y)
156
276
  }
157
277
  }
@@ -0,0 +1,29 @@
1
+ export default {
2
+ getAngle(startX, startY, endX, endY) {
3
+ return Math.atan2(endY - startY, endX - startX);
4
+ },
5
+
6
+ snapAngle(angle) {
7
+ let degrees = (angle * 180) / Math.PI;
8
+ degrees = (degrees + 360) % 360;
9
+ const snappedDegrees = Math.round(degrees / 15) * 15;
10
+ return (snappedDegrees * Math.PI) / 180;
11
+ },
12
+
13
+ getSnappedEndPoint(startX, startY, endX, endY, isShiftPressed) {
14
+ if (!isShiftPressed) {
15
+ return { x: endX, y: endY };
16
+ }
17
+
18
+ const angle = this.getAngle(startX, startY, endX, endY);
19
+ const snappedAngle = this.snapAngle(angle);
20
+ const distance = Math.sqrt(
21
+ Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2)
22
+ );
23
+
24
+ return {
25
+ x: startX + distance * Math.cos(snappedAngle),
26
+ y: startY + distance * Math.sin(snappedAngle)
27
+ };
28
+ }
29
+ }
@@ -0,0 +1,19 @@
1
+ export default {
2
+ getConstrainedSize(startX, startY, currentX, currentY, isShiftPressed) {
3
+ if (!isShiftPressed) {
4
+ return {
5
+ width: currentX - startX,
6
+ height: currentY - startY
7
+ };
8
+ }
9
+
10
+ const width = currentX - startX;
11
+ const height = currentY - startY;
12
+ const size = Math.max(Math.abs(width), Math.abs(height));
13
+
14
+ return {
15
+ width: size * Math.sign(width),
16
+ height: size * Math.sign(height)
17
+ };
18
+ }
19
+ }
@@ -2,6 +2,8 @@ import Konva from 'konva'
2
2
  import Properties from '../properties'
3
3
  import Canvas from '../canvas'
4
4
  import Histories from '../histories'
5
+ import AngleSnap from './helper/angleSnap'
6
+
5
7
  export default {
6
8
  temporalShape: null,
7
9
  startX: 0,
@@ -20,9 +22,16 @@ export default {
20
22
  this.temporalShape = newLine
21
23
  Canvas.layer.add(this.temporalShape)
22
24
  },
23
- change({ offsetX, offsetY }) {
25
+ change({ offsetX, offsetY, shiftKey }) {
24
26
  if (Canvas.isDrawing) {
25
- this.temporalShape.points([this.startX, this.startY, offsetX, offsetY]);
27
+ const endPoint = AngleSnap.getSnappedEndPoint(
28
+ this.startX,
29
+ this.startY,
30
+ offsetX,
31
+ offsetY,
32
+ shiftKey
33
+ );
34
+ this.temporalShape.points([this.startX, this.startY, endPoint.x, endPoint.y]);
26
35
  }
27
36
  },
28
37
  finish() {
@@ -2,6 +2,8 @@ import Konva from 'konva'
2
2
  import Properties from '../properties'
3
3
  import Canvas from '../canvas'
4
4
  import Histories from '../histories'
5
+ import ShapeConstraint from './helper/shapeConstraint'
6
+
5
7
  export default {
6
8
  temporalShape: null,
7
9
  startX: 0,
@@ -26,10 +28,17 @@ export default {
26
28
  this.temporalShape = newRectangle
27
29
  Canvas.layer.add(this.temporalShape)
28
30
  },
29
- change({ offsetX, offsetY }) {
31
+ change({ offsetX, offsetY, shiftKey }) {
30
32
  if (Canvas.isDrawing) {
31
- this.temporalShape.width(offsetX - this.startX)
32
- this.temporalShape.height(offsetY - this.startY)
33
+ const { width, height } = ShapeConstraint.getConstrainedSize(
34
+ this.startX,
35
+ this.startY,
36
+ offsetX,
37
+ offsetY,
38
+ shiftKey
39
+ );
40
+ this.temporalShape.width(width);
41
+ this.temporalShape.height(height);
33
42
  }
34
43
  },
35
44
  finish() {
@@ -39,8 +39,43 @@ export default {
39
39
  return this.selector !== null
40
40
  },
41
41
  _isInSelector(node) {
42
- const nodeRect = node.getClientRect()
43
42
  const selectorRect = this.selector.getClientRect()
44
- return Konva.Util.haveIntersection(selectorRect, nodeRect)
43
+
44
+ if (node.className === 'Line' || node.className === 'Arrow') {
45
+ return this._isLineFullyContained(node, selectorRect)
46
+ }
47
+
48
+ return this._isShapeFullyContained(node, selectorRect)
49
+ },
50
+ _isLineFullyContained(node, selectorRect) {
51
+ const points = node.points()
52
+ for (let i = 0; i < points.length; i += 2) {
53
+ const point = node.getAbsoluteTransform().point({
54
+ x: points[i],
55
+ y: points[i + 1]
56
+ })
57
+
58
+ if (!this._isPointInRect(point, selectorRect)) {
59
+ return false
60
+ }
61
+ }
62
+ return true
63
+ },
64
+ _isShapeFullyContained(node, selectorRect) {
65
+ const nodeRect = node.getClientRect()
66
+ const corners = [
67
+ { x: nodeRect.x, y: nodeRect.y }, // top-left
68
+ { x: nodeRect.x + nodeRect.width, y: nodeRect.y }, // top-right
69
+ { x: nodeRect.x, y: nodeRect.y + nodeRect.height }, // bottom-left
70
+ { x: nodeRect.x + nodeRect.width, y: nodeRect.y + nodeRect.height } // bottom-right
71
+ ]
72
+
73
+ return corners.every(point => this._isPointInRect(point, selectorRect))
74
+ },
75
+ _isPointInRect(point, rect) {
76
+ return point.x >= rect.x &&
77
+ point.x <= rect.x + rect.width &&
78
+ point.y >= rect.y &&
79
+ point.y <= rect.y + rect.height
45
80
  }
46
81
  }
@@ -11,6 +11,15 @@ export default {
11
11
  },
12
12
  initialize() {
13
13
  this.transformer = new Konva.Transformer();
14
+
15
+ // 创建一个透明的拖动区域
16
+ this.dragArea = new Konva.Rect({
17
+ fill: 'transparent',
18
+ draggable: true,
19
+ listening: true
20
+ });
21
+
22
+ Canvas.layer.add(this.dragArea);
14
23
  Canvas.layer.add(this.transformer);
15
24
  },
16
25
  copySelectedNodes() {
@@ -39,6 +48,10 @@ export default {
39
48
  });
40
49
  },
41
50
  selectNode(targetNode, isAppending = false) {
51
+ if (!this._shouldSelectNode(targetNode)) {
52
+ return;
53
+ }
54
+
42
55
  const isSelected = this._nodes().indexOf(targetNode) >= 0 || this.linearTransformer.node === targetNode;
43
56
  if (!isAppending && !isSelected) {
44
57
  this.selectNodes([targetNode]);
@@ -64,20 +77,22 @@ export default {
64
77
  this._selectLinearNode(targetNodes[0]);
65
78
  } else {
66
79
  this._nodes(targetNodes);
67
- if (targetNodes.length === 1) {
68
- this.transformer.enabledAnchors(['top-left', 'top-center', 'top-right', 'middle-right', 'middle-left', 'bottom-left', 'bottom-center', 'bottom-right']);
80
+ if (targetNodes.length > 0) {
81
+ this._updateDragArea(targetNodes);
82
+ this._updateTransformerAnchors(targetNodes);
69
83
  } else {
70
- this.transformer.enabledAnchors(['top-left', 'top-right', 'bottom-left', 'bottom-right']);
84
+ this.dragArea.visible(false);
71
85
  }
72
- Properties.isTextNodesSelected = this._hasSelectedOnlyTextNodes()
86
+ Properties.isTextNodesSelected = this._hasSelectedOnlyTextNodes();
73
87
  }
74
88
  Properties.setProperties(this._getCommonPropertiesOfSelectedNodes());
75
89
  },
76
90
  deselectAllNodes() {
91
+ this.dragArea.visible(false);
77
92
  this.selectNodes([]);
78
93
  if (this.linearTransformer.node) {
79
94
  this.linearTransformer.node.destroy();
80
- this._removeLinearTransformer()
95
+ this._removeLinearTransformer();
81
96
  }
82
97
  },
83
98
  selectAllNodes() {
@@ -211,5 +226,75 @@ export default {
211
226
  return hasOnlyTextNodes;
212
227
  }
213
228
  return false;
229
+ },
230
+ _shouldSelectNode(node) {
231
+ if ((node.className === 'Rect' || node.className === 'Ellipse') &&
232
+ (!node.attrs.fill || node.attrs.fill === 'transparent')) {
233
+ const stage = Canvas.stage;
234
+ const mousePos = stage.getPointerPosition();
235
+ return this._isNearBorder(node, mousePos);
236
+ }
237
+ return true;
238
+ },
239
+ _isNearBorder(node, point) {
240
+ const transform = node.getAbsoluteTransform().copy();
241
+ const localPoint = transform.invert().point(point);
242
+
243
+ const threshold = node.attrs.strokeWidth * 2;
244
+
245
+ if (node.className === 'Rect') {
246
+ const width = node.width();
247
+ const height = node.height();
248
+
249
+ const nearHorizontalEdge = (localPoint.y >= -threshold && localPoint.y <= threshold) ||
250
+ (localPoint.y >= height - threshold && localPoint.y <= height + threshold);
251
+ const nearVerticalEdge = (localPoint.x >= -threshold && localPoint.x <= threshold) ||
252
+ (localPoint.x >= width - threshold && localPoint.x <= width + threshold);
253
+
254
+ return (nearHorizontalEdge && localPoint.x >= -threshold && localPoint.x <= width + threshold) ||
255
+ (nearVerticalEdge && localPoint.y >= -threshold && localPoint.y <= height + threshold);
256
+ }
257
+
258
+ if (node.className === 'Ellipse') {
259
+ const radiusX = node.radiusX();
260
+ const radiusY = node.radiusY();
261
+
262
+ const normalizedX = localPoint.x / radiusX;
263
+ const normalizedY = localPoint.y / radiusY;
264
+ const distanceFromCenter = Math.sqrt(normalizedX * normalizedX + normalizedY * normalizedY);
265
+
266
+ return Math.abs(distanceFromCenter - 1) < threshold / Math.min(radiusX, radiusY);
267
+ }
268
+
269
+ return true;
270
+ },
271
+ _updateDragArea(targetNodes) {
272
+ const box = this.transformer.getClientRect();
273
+ this.dragArea.setAttrs({
274
+ x: box.x,
275
+ y: box.y,
276
+ width: box.width,
277
+ height: box.height,
278
+ visible: true
279
+ });
280
+
281
+ this.dragArea.off('dragmove');
282
+ this.dragArea.on('dragmove', () => {
283
+ const dx = this.dragArea.x() - box.x;
284
+ const dy = this.dragArea.y() - box.y;
285
+ targetNodes.forEach(node => {
286
+ node.x(node.x() + dx);
287
+ node.y(node.y() + dy);
288
+ });
289
+ box.x = this.dragArea.x();
290
+ box.y = this.dragArea.y();
291
+ });
292
+ },
293
+ _updateTransformerAnchors(targetNodes) {
294
+ if (targetNodes.length === 1) {
295
+ this.transformer.enabledAnchors(['top-left', 'top-center', 'top-right', 'middle-right', 'middle-left', 'bottom-left', 'bottom-center', 'bottom-right']);
296
+ } else {
297
+ this.transformer.enabledAnchors(['top-left', 'top-right', 'bottom-left', 'bottom-right']);
298
+ }
214
299
  }
215
300
  }