jellies-draw 0.1.11 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jellies-draw",
3
- "version": "0.1.11",
3
+ "version": "0.2.1",
4
4
  "description": "A drawer for jellies",
5
5
  "private": false,
6
6
  "main": "./src/index.js",
@@ -35,8 +35,14 @@ export default {
35
35
  Histories.record();
36
36
  this.stage.on('pointerdown', this.pointerDownHandler);
37
37
  this.stage.on('pointermove', this.pointerMoveHandler);
38
- this.stage.on('pointerup', this.pointerUpHandler);
38
+ this.stage.on('pointerup', this.handleCanvasPointerUp);
39
39
  this.stage.on('click tap', this.clickHandler);
40
+ this.stage.on('pointerdown', () => {
41
+ if (Properties.isUsingDrawingTool) {
42
+ window.addEventListener('pointerup', this.handleGlobalPointerUp, { once: true });
43
+ }
44
+ });
45
+ this.stage.on('mousemove', this._handleMouseMove.bind(this));
40
46
  },
41
47
  generateEventHandlers() {
42
48
  this.resizeHandler = this._handleResize.bind(this);
@@ -44,7 +50,17 @@ export default {
44
50
  this.keyupHandler = this._handleKeyup.bind(this);
45
51
  this.pointerDownHandler = this.handlePointerDown.bind(this);
46
52
  this.pointerMoveHandler = this.handlePointerMove.bind(this);
47
- this.pointerUpHandler = this.handlePointerUp.bind(this);
53
+ this.handleCanvasPointerUp = (event) => {
54
+ if (event.target.getStage()) {
55
+ this.handlePointerUp(event);
56
+ }
57
+ };
58
+ this.handleGlobalPointerUp = (event) => {
59
+ const container = this.stage.container();
60
+ if (!container.contains(event.target)) {
61
+ this.handlePointerUp(event);
62
+ }
63
+ };
48
64
  this.clickHandler = this.handleClick.bind(this);
49
65
  },
50
66
  clear() {
@@ -54,7 +70,7 @@ export default {
54
70
  destroy() {
55
71
  this.stage.off('pointerdown', this.pointerDownHandler);
56
72
  this.stage.off('pointermove', this.pointerMoveHandler);
57
- this.stage.off('pointerup', this.pointerUpHandler);
73
+ this.stage.off('pointerup', this.handleCanvasPointerUp);
58
74
  this.stage.off('click tap', this.clickHandler);
59
75
  document.removeEventListener('keydown', this.keydownHandler);
60
76
  document.removeEventListener('keyup', this.keyupHandler);
@@ -178,5 +194,43 @@ export default {
178
194
  return;
179
195
  }
180
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
+ // Reset cursor to default when over stage
206
+ if (target === this.stage) {
207
+ this.stage.container().style.cursor = 'default';
208
+ return;
209
+ }
210
+
211
+ // Handle cursor for nodes
212
+ if (target.hasName('node')) {
213
+ if (this._shouldShowMoveCursor(target)) {
214
+ this.stage.container().style.cursor = 'move';
215
+ } else {
216
+ this.stage.container().style.cursor = 'default';
217
+ }
218
+ }
219
+ },
220
+ _shouldShowMoveCursor(node) {
221
+ // Always show move cursor for non-shape nodes
222
+ if (!['Rect', 'Circle', 'Ellipse'].includes(node.className)) {
223
+ return true;
224
+ }
225
+
226
+ // Show move cursor for filled shapes
227
+ if (node.attrs.fill && node.attrs.fill !== 'transparent') {
228
+ return true;
229
+ }
230
+
231
+ // For unfilled shapes, only show move cursor near border
232
+ const mousePos = this.stage.getPointerPosition();
233
+ return Transformer._isNearBorder(node, mousePos);
181
234
  }
182
235
  }
236
+
@@ -1,51 +1,278 @@
1
1
  import Konva from 'konva'
2
2
  import Properties from '../properties'
3
3
  import Canvas from '../canvas'
4
+
4
5
  export default {
5
- temporalCurveTrajectory: null,
6
6
  temporalShape: null,
7
7
  startX: 0,
8
8
  startY: 0,
9
+ lastX: 0,
10
+ lastY: 0,
11
+ erasedNodes: new Set(),
12
+
9
13
  show({ offsetX, offsetY }) {
10
- Canvas.isDrawing = true;
14
+ Canvas.isDrawing = true
11
15
  this.startX = offsetX
12
16
  this.startY = offsetY
13
- this.temporalCurveTrajectory = null
14
- const strokeWidth = Properties.strokeWidth
15
- const newCurve = new Konva.Line({
16
- points: [offsetX, offsetY, offsetX, offsetY],
17
- stroke: 'transparent',
18
- strokeWidth,
19
- name: 'node',
20
- bezier: true,
21
- lineCap: 'round',
22
- strokeScaleEnabled: false
23
- });
17
+ this.lastX = offsetX
18
+ this.lastY = offsetY
19
+ this.erasedNodes.clear()
20
+
21
+ const newCurve = this._generateEraserLine(offsetX, offsetY)
24
22
  this.temporalShape = newCurve
25
- Canvas.layer.add(this.temporalShape);
23
+ Canvas.layer.add(this.temporalShape)
26
24
  },
25
+
27
26
  change({ offsetX, offsetY }) {
28
- if (Canvas.isDrawing) {
29
- if (!this.temporalCurveTrajectory) {
30
- this.temporalCurveTrajectory = [this.startX, this.startY]
27
+ if (!Canvas.isDrawing) return
28
+
29
+ this._interpolateAndCheck(this.lastX, this.lastY, offsetX, offsetY)
30
+ this._updateEraserPath(offsetX, offsetY)
31
+
32
+ this.lastX = offsetX
33
+ this.lastY = offsetY
34
+ Canvas.layer.batchDraw()
35
+ },
36
+
37
+ finish() {
38
+ this._destroyErasedNodes()
39
+ this._cleanupTemporalShape()
40
+ Canvas.layer.batchDraw()
41
+ },
42
+
43
+ _generateEraserLine(x, y) {
44
+ return new Konva.Line({
45
+ points: [x, y],
46
+ stroke: '#000000',
47
+ strokeWidth: Properties.strokeWidth * 2,
48
+ opacity: 0,
49
+ lineCap: 'round',
50
+ lineJoin: 'round',
51
+ tension: 0.5,
52
+ strokeScaleEnabled: false
53
+ })
54
+ },
55
+
56
+ _updateEraserPath(x, y) {
57
+ const points = this.temporalShape.points()
58
+ points.push(x, y)
59
+ this.temporalShape.points(points)
60
+ },
61
+
62
+ _checkAndMarkIntersections(x, y) {
63
+ const eraserRadius = Properties.strokeWidth
64
+
65
+ Canvas.stage.find('.node').forEach(node => {
66
+ if (node === this.temporalShape) return
67
+
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)) {
78
+ this._markNodeForErasure(node)
79
+ }
31
80
  }
32
- this.temporalCurveTrajectory.push(offsetX, offsetY)
33
- this.temporalShape.points(this.temporalCurveTrajectory);
34
- Canvas.stage.find('.node')
35
- .filter(node => this._hasIntersectionOnEraserCurve(node))
36
- .forEach(node => node.opacity(0.1));
81
+ })
82
+ },
83
+
84
+ _markNodeForErasure(node) {
85
+ node.opacity(0.2)
86
+ this.erasedNodes.add(node)
87
+ },
88
+
89
+ _destroyErasedNodes() {
90
+ this.erasedNodes.forEach(node => node.destroy())
91
+ this.erasedNodes.clear()
92
+ },
93
+
94
+ _cleanupTemporalShape() {
95
+ if (this.temporalShape) {
96
+ this.temporalShape.destroy()
97
+ this.temporalShape = null
37
98
  }
38
99
  },
39
- finish() {
40
- Canvas.stage.find('.node')
41
- .filter(node => this._hasIntersectionOnEraserCurve(node))
42
- .forEach(node => node.remove())
43
- this.temporalShape = null
100
+
101
+ _checkIntersection(node, point) {
102
+ if (node.attrs.nodeType === 'pen') {
103
+ return this._checkPenIntersection(node, point)
104
+ }
105
+
106
+ switch (node.className) {
107
+ case 'Line':
108
+ case 'Arrow':
109
+ return this._checkLineIntersection(node, point)
110
+ case 'Circle':
111
+ case 'Ellipse':
112
+ return this._checkEllipseIntersection(node, point)
113
+ case 'Rect':
114
+ return this._checkRectIntersection(node, point)
115
+ default:
116
+ return this._checkDefaultIntersection(node)
117
+ }
44
118
  },
45
- _hasIntersectionOnEraserCurve(node) {
119
+
120
+ _checkPenIntersection(node, point) {
121
+ const localPoint = this._transformPointToLocal(node, point)
122
+ const { points, widths } = node.attrs
123
+ const eraserRadius = Properties.strokeWidth
124
+
125
+ for (let i = 0; i < points.length - 1; i++) {
126
+ const distance = this._getDistanceToLineSegment(
127
+ localPoint.x,
128
+ localPoint.y,
129
+ points[i][0],
130
+ points[i][1],
131
+ points[i + 1][0],
132
+ points[i + 1][1]
133
+ )
134
+
135
+ const combinedWidth = (widths[i] + eraserRadius) / 2
136
+ if (distance < combinedWidth) {
137
+ return true
138
+ }
139
+ }
140
+ return false
141
+ },
142
+
143
+ _checkDefaultIntersection(node) {
46
144
  return Konva.Util.haveIntersection(
47
145
  this.temporalShape.getClientRect(),
48
146
  node.getClientRect()
49
- );
147
+ )
148
+ },
149
+
150
+ _transformPointToLocal(node, point) {
151
+ const transform = node.getTransform()
152
+ return transform.copy().invert().point(point)
153
+ },
154
+
155
+ _getDistanceToLineSegment(px, py, x1, y1, x2, y2) {
156
+ const A = px - x1
157
+ const B = py - y1
158
+ const C = x2 - x1
159
+ const D = y2 - y1
160
+
161
+ const dot = A * C + B * D
162
+ const lenSq = C * C + D * D
163
+ const param = lenSq !== 0 ? dot / lenSq : -1
164
+
165
+ if (param < 0) return this._getDistance(px, py, x1, y1)
166
+ if (param > 1) return this._getDistance(px, py, x2, y2)
167
+
168
+ return this._getDistance(
169
+ px,
170
+ py,
171
+ x1 + param * C,
172
+ y1 + param * D
173
+ )
174
+ },
175
+
176
+ _getDistance(x1, y1, x2, y2) {
177
+ const dx = x1 - x2
178
+ const dy = y1 - y2
179
+ return Math.sqrt(dx * dx + dy * dy)
180
+ },
181
+
182
+ _checkLineIntersection(node, point) {
183
+ const points = node.points()
184
+ const transform = node.getTransform()
185
+ const localPoint = transform.copy().invert().point(point)
186
+ const eraserRadius = Properties.strokeWidth
187
+
188
+ for (let i = 0; i < points.length - 2; i += 2) {
189
+ const distance = this._getDistanceToLineSegment(
190
+ localPoint.x,
191
+ localPoint.y,
192
+ points[i],
193
+ points[i + 1],
194
+ points[i + 2],
195
+ points[i + 3]
196
+ )
197
+
198
+ const combinedWidth = (node.strokeWidth() + eraserRadius) / 2
199
+ if (distance < combinedWidth) {
200
+ return true
201
+ }
202
+ }
203
+ return false
204
+ },
205
+
206
+ _checkEllipseIntersection(node, point) {
207
+ const transform = node.getTransform()
208
+ const localPoint = transform.copy().invert().point(point)
209
+
210
+ const radiusX = node.radiusX()
211
+ const radiusY = node.radiusY()
212
+
213
+ // Normalize point to unit circle
214
+ const normalizedX = localPoint.x / radiusX
215
+ const normalizedY = localPoint.y / radiusY
216
+
217
+ // Check if point is within ellipse border
218
+ const distanceFromCenter = Math.sqrt(
219
+ normalizedX * normalizedX + normalizedY * normalizedY
220
+ )
221
+
222
+ if (node.attrs.fill && node.attrs.fill !== 'transparent') {
223
+ // For filled ellipses, allow erasing anywhere inside
224
+ return distanceFromCenter <= 1
225
+ }
226
+ // For unfilled ellipses, only erase near the border
227
+ return Math.abs(distanceFromCenter - 1) < Properties.strokeWidth / Math.min(radiusX, radiusY)
228
+ },
229
+
230
+ _checkRectIntersection(node, point) {
231
+ const transform = node.getTransform()
232
+ const localPoint = transform.copy().invert().point(point)
233
+
234
+ const width = node.width()
235
+ const height = node.height()
236
+ const strokeWidth = Properties.strokeWidth
237
+
238
+ if (node.attrs.fill && node.attrs.fill !== 'transparent') {
239
+ // For filled rectangles, allow erasing anywhere inside
240
+ return localPoint.x >= -strokeWidth &&
241
+ localPoint.x <= width + strokeWidth &&
242
+ localPoint.y >= -strokeWidth &&
243
+ localPoint.y <= height + strokeWidth
244
+ }
245
+ // For unfilled rectangles, only erase near the border
246
+ return (Math.abs(localPoint.x) < strokeWidth ||
247
+ Math.abs(localPoint.x - width) < strokeWidth ||
248
+ Math.abs(localPoint.y) < strokeWidth ||
249
+ Math.abs(localPoint.y - height) < strokeWidth) &&
250
+ localPoint.x >= -strokeWidth &&
251
+ localPoint.x <= width + strokeWidth &&
252
+ localPoint.y >= -strokeWidth &&
253
+ localPoint.y <= height + strokeWidth
254
+ },
255
+
256
+ _interpolateAndCheck(x1, y1, x2, y2) {
257
+ const distance = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
258
+ const steps = Math.ceil(distance / (Properties.strokeWidth / 2))
259
+
260
+ if (steps > 1) {
261
+ for (let i = 1; i < steps; i++) {
262
+ const ratio = i / steps
263
+ const x = x1 + (x2 - x1) * ratio
264
+ const y = y1 + (y2 - y1) * ratio
265
+ this._checkAndMarkIntersections(x, y)
266
+ }
267
+ } else {
268
+ this._checkAndMarkIntersections(x2, y2)
269
+ }
270
+ },
271
+
272
+ _checkRectOverlap(rect1, rect2) {
273
+ return !(rect1.x > rect2.x + rect2.width ||
274
+ rect1.x + rect1.width < rect2.x ||
275
+ rect1.y > rect2.y + rect2.height ||
276
+ rect1.y + rect1.height < rect2.y)
50
277
  }
51
278
  }
@@ -2,62 +2,373 @@ import Konva from 'konva'
2
2
  import Properties from '../properties'
3
3
  import Canvas from '../canvas'
4
4
  import Histories from '../histories'
5
+
5
6
  export default {
6
- temporalCurveTrajectory: null,
7
7
  temporalShape: null,
8
8
  startX: 0,
9
9
  startY: 0,
10
+ lastX: 0,
11
+ lastY: 0,
12
+ lastTime: 0,
13
+ points: [],
14
+ widths: [],
15
+ boundMinX: 0,
16
+ boundMinY: 0,
17
+ boundMaxX: 0,
18
+ boundMaxY: 0,
19
+
10
20
  show({ offsetX, offsetY }) {
11
- Canvas.isDrawing = true;
21
+ Canvas.isDrawing = true
12
22
  this.startX = offsetX
13
23
  this.startY = offsetY
14
- this.temporalCurveTrajectory = null
15
- const stroke = Properties.strokeColor
16
- const strokeWidth = Properties.strokeWidth
24
+ this.lastX = offsetX
25
+ this.lastY = offsetY
26
+ this.lastTime = Date.now()
27
+
28
+ this.boundMinX = offsetX
29
+ this.boundMinY = offsetY
30
+ this.boundMaxX = offsetX
31
+ this.boundMaxY = offsetY
32
+
33
+ this.points = [[0, 0]]
34
+ this.widths = [Properties.strokeWidth]
35
+
17
36
  const newCurve = this.generate({
18
- points: [offsetX, offsetY, offsetX, offsetY],
19
- stroke,
20
- strokeWidth
37
+ x: offsetX,
38
+ y: offsetY,
39
+ points: this.points,
40
+ widths: this.widths,
41
+ stroke: Properties.strokeColor
21
42
  })
43
+
22
44
  this.temporalShape = newCurve
23
45
  Canvas.layer.add(this.temporalShape)
24
46
  },
47
+
25
48
  change({ offsetX, offsetY }) {
26
- if (Canvas.isDrawing) {
27
- if (!this.temporalCurveTrajectory) {
28
- this.temporalCurveTrajectory = [this.startX, this.startY]
29
- }
30
- this.temporalCurveTrajectory.push(offsetX, offsetY)
31
- this.temporalShape.points(this.temporalCurveTrajectory);
32
- }
49
+ if (!Canvas.isDrawing) return
50
+
51
+ const currentTime = Date.now()
52
+ const strokeWidth = this._calculateStrokeWidth(offsetX, offsetY, currentTime)
53
+
54
+ this._updateBoundaries(offsetX, offsetY)
55
+
56
+ const relativeX = offsetX - this.startX
57
+ const relativeY = offsetY - this.startY
58
+
59
+ this.points.push([relativeX, relativeY])
60
+ this.widths.push(strokeWidth)
61
+
62
+ Canvas.layer.batchDraw()
63
+
64
+ this.lastX = offsetX
65
+ this.lastY = offsetY
66
+ this.lastTime = currentTime
33
67
  },
68
+
34
69
  finish() {
70
+ if (!this.temporalShape) return
71
+
72
+ const maxStrokeWidth = Math.max(...this.widths)
73
+ const padding = maxStrokeWidth
74
+
75
+ this._adjustShapeBoundaries(padding)
76
+ Canvas.layer.batchDraw()
35
77
  this.temporalShape = null
36
78
  },
37
- generate({ x, y, scaleX, scaleY, skewX, rotation, points, stroke, strokeWidth }) {
38
- const newLine = new Konva.Line({
39
- x,
40
- y,
41
- scaleX,
42
- scaleY,
43
- skewX,
44
- rotation,
45
- points,
79
+
80
+ generate({ points, widths, stroke, x, y, width, height }) {
81
+ const tool = this
82
+
83
+ const shape = new Konva.Shape({
84
+ x: x || 0,
85
+ y: y || 0,
86
+ width: width || 0,
87
+ height: height || 0,
46
88
  stroke,
47
- strokeWidth,
89
+ points,
90
+ widths,
48
91
  name: 'node',
49
92
  nodeType: 'pen',
50
- lineCap: 'round',
51
- bezier: false,
52
- strokeScaleEnabled: false
93
+ strokeScaleEnabled: false,
94
+
95
+ sceneFunc: (context, shape) => {
96
+ const smoothPoints = tool._calculateSmoothPoints(shape.attrs.points)
97
+ tool._drawSmoothLine(context, smoothPoints, shape.attrs.widths, shape.attrs.stroke)
98
+ },
99
+
100
+ hitFunc: (context, shape) => {
101
+ const smoothPoints = tool._calculateSmoothPoints(shape.attrs.points)
102
+ tool._drawHitPath(context, smoothPoints, shape.attrs.widths)
103
+ context.fillStrokeShape(shape)
104
+ }
53
105
  })
54
- this.bindEvents(newLine)
55
- return newLine
56
- },
57
- bindEvents(newLine) {
58
- newLine.off('transformend')
59
- newLine.off('dragend')
60
- newLine.on('transformend', Histories.record)
61
- newLine.on('dragend', Histories.record)
106
+
107
+ this.bindEvents(shape)
108
+ return shape
109
+ },
110
+
111
+ bindEvents(shape) {
112
+ shape.off('transformend')
113
+ shape.off('dragend')
114
+ shape.on('transformend', Histories.record)
115
+ shape.on('dragend', Histories.record)
116
+ },
117
+
118
+ _calculateStrokeWidth(offsetX, offsetY, currentTime) {
119
+ const timeDiff = currentTime - this.lastTime
120
+ const distance = Math.sqrt(
121
+ Math.pow(offsetX - this.lastX, 2) +
122
+ Math.pow(offsetY - this.lastY, 2)
123
+ )
124
+ const velocity = distance / (timeDiff + 1)
125
+
126
+ const baseWidth = Properties.strokeWidth
127
+ const minWidth = baseWidth * 0.7
128
+ const maxWidth = baseWidth * 1.2
129
+
130
+ if (velocity > 0.7) return minWidth
131
+ if (velocity < 0.2) return maxWidth
132
+ return maxWidth - (velocity - 0.2) * (maxWidth - minWidth) / 0.5
133
+ },
134
+
135
+ _updateBoundaries(x, y) {
136
+ this.boundMinX = Math.min(this.boundMinX, x)
137
+ this.boundMinY = Math.min(this.boundMinY, y)
138
+ this.boundMaxX = Math.max(this.boundMaxX, x)
139
+ this.boundMaxY = Math.max(this.boundMaxY, y)
140
+ },
141
+
142
+ _adjustShapeBoundaries(padding) {
143
+ this.temporalShape.x(this.boundMinX - padding)
144
+ this.temporalShape.y(this.boundMinY - padding)
145
+ this.temporalShape.width(this.boundMaxX - this.boundMinX + padding * 2)
146
+ this.temporalShape.height(this.boundMaxY - this.boundMinY + padding * 2)
147
+
148
+ const adjustedPoints = this.points.map(point => [
149
+ point[0] - (this.boundMinX - this.startX - padding),
150
+ point[1] - (this.boundMinY - this.startY - padding)
151
+ ])
152
+ this.temporalShape.attrs.points = adjustedPoints
153
+ },
154
+
155
+ _drawSmoothLine(context, points, widths, stroke) {
156
+ context.beginPath()
157
+ context.strokeStyle = stroke
158
+ context.lineCap = 'round'
159
+ context.lineJoin = 'round'
160
+ context.moveTo(points[0][0], points[0][1])
161
+
162
+ for (let i = 1; i < points.length; i++) {
163
+ const width = this._interpolateWidth(i, points.length, widths)
164
+ context.lineWidth = width
165
+ context.lineTo(points[i][0], points[i][1])
166
+ context.stroke()
167
+ context.beginPath()
168
+ context.moveTo(points[i][0], points[i][1])
169
+ }
170
+ },
171
+
172
+ _drawHitPath(context, points, widths) {
173
+ context.beginPath()
174
+ context.lineCap = 'round'
175
+ context.lineJoin = 'round'
176
+ context.moveTo(points[0][0], points[0][1])
177
+
178
+ this._drawForwardPath(context, points, widths)
179
+ this._drawReversePath(context, points, widths)
180
+ },
181
+
182
+ _drawForwardPath(context, points, widths) {
183
+ for (let i = 1; i < points.length; i++) {
184
+ const width = this._interpolateWidth(i, points.length, widths) + 10
185
+ context.lineWidth = width
186
+ context.lineTo(points[i][0], points[i][1])
187
+ }
188
+ },
189
+
190
+ _drawReversePath(context, points, widths) {
191
+ for (let i = points.length - 1; i >= 0; i--) {
192
+ const width = this._interpolateWidth(i, points.length, widths) + 10
193
+ context.lineWidth = width
194
+ context.lineTo(points[i][0], points[i][1])
195
+ }
196
+ },
197
+
198
+ _interpolateWidth(index, total, widths) {
199
+ const progress = index / total
200
+ const widthIndex = Math.floor(progress * (widths.length - 1))
201
+ const nextWidthIndex = Math.min(widthIndex + 1, widths.length - 1)
202
+ const widthProgress = progress * (widths.length - 1) - widthIndex
203
+ return widths[widthIndex] * (1 - widthProgress) +
204
+ widths[nextWidthIndex] * widthProgress
205
+ },
206
+
207
+ _calculateSmoothPoints(points) {
208
+ // 首先检查是否是近似直线
209
+ const isNearlyLine = this._checkIfNearlyLine(points)
210
+
211
+ // 如果是近似直线,使用 Douglas-Peucker 算法简化
212
+ if (isNearlyLine) {
213
+ return this._simplifyDouglasPeucker(points, 2)
214
+ }
215
+
216
+ const smoothPoints = []
217
+ for (let i = 0; i < points.length - 1; i++) {
218
+ const p0 = i > 0 ? points[i - 1] : points[i]
219
+ const p1 = points[i]
220
+ const p2 = points[i + 1]
221
+ const p3 = i < points.length - 2 ? points[i + 2] : p2
222
+
223
+ // 检查是否是锐角
224
+ if (i > 0 && i < points.length - 2) {
225
+ const angle = this._getAngle(p1, p2, p0)
226
+
227
+ if (angle < Math.PI * 0.6 && this._getSegmentLength(p1, p2) > 20) {
228
+ smoothPoints.push(p1)
229
+ smoothPoints.push(p2)
230
+ continue
231
+ }
232
+ }
233
+
234
+ this._addCatmullRomPoints(smoothPoints, p0, p1, p2, p3)
235
+ }
236
+ smoothPoints.push(points[points.length - 1])
237
+ return smoothPoints
238
+ },
239
+
240
+ _checkIfNearlyLine(points) {
241
+ if (points.length < 3) return true
242
+
243
+ // 计算所有点到起点和终点连线的最大偏差
244
+ const start = points[0]
245
+ const end = points[points.length - 1]
246
+ let maxDeviation = 0
247
+
248
+ for (let i = 1; i < points.length - 1; i++) {
249
+ const deviation = this._getPointToLineDistance(
250
+ points[i],
251
+ start,
252
+ end
253
+ )
254
+ maxDeviation = Math.max(maxDeviation, deviation)
255
+ }
256
+
257
+ // 检查所有相邻点之间的角度
258
+ for (let i = 1; i < points.length - 1; i++) {
259
+ const angle = this._getAngle(points[i-1], points[i], points[i+1])
260
+ // 176度 ≈ 3.07 弧度
261
+ if (angle < 3.07) {
262
+ return false
263
+ }
264
+ }
265
+
266
+ // 如果最大偏差小于阈值且所有角度都接近180度,认为是近似直线
267
+ return maxDeviation < 5
268
+ },
269
+
270
+ _getPointToLineDistance(point, lineStart, lineEnd) {
271
+ const dx = lineEnd[0] - lineStart[0]
272
+ const dy = lineEnd[1] - lineStart[1]
273
+ const lineLengthSq = dx * dx + dy * dy
274
+
275
+ if (lineLengthSq === 0) return 0
276
+
277
+ const t = ((point[0] - lineStart[0]) * dx +
278
+ (point[1] - lineStart[1]) * dy) / lineLengthSq
279
+
280
+ if (t < 0) return this._getSegmentLength(point, lineStart)
281
+ if (t > 1) return this._getSegmentLength(point, lineEnd)
282
+
283
+ const projX = lineStart[0] + t * dx
284
+ const projY = lineStart[1] + t * dy
285
+
286
+ return Math.sqrt(
287
+ Math.pow(point[0] - projX, 2) +
288
+ Math.pow(point[1] - projY, 2)
289
+ )
290
+ },
291
+
292
+ _getAngle(p1, p2, p0) {
293
+ const v1 = [p1[0] - p0[0], p1[1] - p0[1]]
294
+ const v2 = [p2[0] - p1[0], p2[1] - p1[1]]
295
+
296
+ // 计算向量夹角
297
+ const dot = v1[0] * v2[0] + v1[1] * v2[1]
298
+ const len1 = Math.sqrt(v1[0] * v1[0] + v1[1] * v1[1])
299
+ const len2 = Math.sqrt(v2[0] * v2[0] + v2[1] * v2[1])
300
+
301
+ return Math.acos(dot / (len1 * len2))
302
+ },
303
+
304
+ _getSegmentLength(p1, p2) {
305
+ const dx = p2[0] - p1[0]
306
+ const dy = p2[1] - p1[1]
307
+ return Math.sqrt(dx * dx + dy * dy)
308
+ },
309
+
310
+ _addCatmullRomPoints(smoothPoints, p0, p1, p2, p3) {
311
+ for (let t = 0; t < 1; t += 0.1) {
312
+ const t2 = t * t
313
+ const t3 = t2 * t
314
+
315
+ const x = 0.5 * (
316
+ (2 * p1[0]) +
317
+ (-p0[0] + p2[0]) * t +
318
+ (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
319
+ (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3
320
+ )
321
+ const y = 0.5 * (
322
+ (2 * p1[1]) +
323
+ (-p0[1] + p2[1]) * t +
324
+ (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
325
+ (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3
326
+ )
327
+
328
+ smoothPoints.push([x, y])
329
+ }
330
+ },
331
+
332
+ _simplifyDouglasPeucker(points, epsilon) {
333
+ if (points.length <= 2) return points
334
+
335
+ // 找到最远点
336
+ let maxDistance = 0
337
+ let maxIndex = 0
338
+
339
+ const firstPoint = points[0]
340
+ const lastPoint = points[points.length - 1]
341
+
342
+ // 找到距离首尾连线最远的点
343
+ for (let i = 1; i < points.length - 1; i++) {
344
+ const distance = this._getPointToLineDistance(
345
+ points[i],
346
+ firstPoint,
347
+ lastPoint
348
+ )
349
+
350
+ if (distance > maxDistance) {
351
+ maxDistance = distance
352
+ maxIndex = i
353
+ }
354
+ }
355
+
356
+ // 如果最大距离大于阈值,递归处理两个子段
357
+ if (maxDistance > epsilon) {
358
+ const firstHalf = this._simplifyDouglasPeucker(
359
+ points.slice(0, maxIndex + 1),
360
+ epsilon
361
+ )
362
+ const secondHalf = this._simplifyDouglasPeucker(
363
+ points.slice(maxIndex),
364
+ epsilon
365
+ )
366
+
367
+ // 合并两个子段,去掉重复的中间点
368
+ return firstHalf.slice(0, -1).concat(secondHalf)
369
+ }
370
+
371
+ // 如果最大距离小于阈值,只保留首尾两点
372
+ return [firstPoint, lastPoint]
62
373
  }
63
374
  }
@@ -39,9 +39,43 @@ export default {
39
39
  return this.selector !== null
40
40
  },
41
41
  _isInSelector(node) {
42
- return Konva.Util.haveIntersection(
43
- this.selector.getClientRect(),
44
- node.getClientRect()
45
- );
42
+ const selectorRect = this.selector.getClientRect()
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
46
80
  }
47
81
  }
@@ -39,6 +39,10 @@ export default {
39
39
  });
40
40
  },
41
41
  selectNode(targetNode, isAppending = false) {
42
+ if (!this._shouldSelectNode(targetNode)) {
43
+ return;
44
+ }
45
+
42
46
  const isSelected = this._nodes().indexOf(targetNode) >= 0 || this.linearTransformer.node === targetNode;
43
47
  if (!isAppending && !isSelected) {
44
48
  this.selectNodes([targetNode]);
@@ -211,5 +215,44 @@ export default {
211
215
  return hasOnlyTextNodes;
212
216
  }
213
217
  return false;
218
+ },
219
+ _shouldSelectNode(node) {
220
+ if ((node.className === 'Rect' || node.className === 'Circle' || node.className === 'Ellipse') &&
221
+ (!node.attrs.fill || node.attrs.fill === 'transparent')) {
222
+ const stage = Canvas.stage;
223
+ const mousePos = stage.getPointerPosition();
224
+ return this._isNearBorder(node, mousePos);
225
+ }
226
+ return true;
227
+ },
228
+ _isNearBorder(node, point) {
229
+ const localPoint = node.getAbsoluteTransform().copy().invert().point(point);
230
+ const threshold = Properties.strokeWidth * 2;
231
+
232
+ if (node.className === 'Rect') {
233
+ const width = node.width();
234
+ const height = node.height();
235
+
236
+ return (Math.abs(localPoint.x) < threshold ||
237
+ Math.abs(localPoint.x - width) < threshold ||
238
+ Math.abs(localPoint.y) < threshold ||
239
+ Math.abs(localPoint.y - height) < threshold) &&
240
+ localPoint.x >= -threshold &&
241
+ localPoint.x <= width + threshold &&
242
+ localPoint.y >= -threshold &&
243
+ localPoint.y <= height + threshold;
244
+ } else if (node.className === 'Circle' || node.className === 'Ellipse') {
245
+ const radiusX = node.radiusX ? node.radiusX() : node.radius();
246
+ const radiusY = node.radiusY ? node.radiusY() : node.radius();
247
+
248
+ const normalizedX = localPoint.x / radiusX;
249
+ const normalizedY = localPoint.y / radiusY;
250
+
251
+ const distanceFromCenter = Math.sqrt(
252
+ normalizedX * normalizedX + normalizedY * normalizedY
253
+ );
254
+ return Math.abs(distanceFromCenter - 1) < threshold / Math.min(radiusX, radiusY);
255
+ }
256
+ return true;
214
257
  }
215
258
  }