瀏覽代碼

Updates arrows

main
Steve Ruiz 4 年之前
父節點
當前提交
daa44f9911

+ 7
- 0
.babelrc 查看文件

1
+{
2
+  "env": {
3
+    "test": {
4
+      "presets": ["next/babel"]
5
+    }
6
+  }
7
+}

+ 0
- 1
cypress.json 查看文件

1
-{}

+ 0
- 5
cypress/fixtures/example.json 查看文件

1
-{
2
-  "name": "Using fixtures to represent data",
3
-  "email": "hello@cypress.io",
4
-  "body": "Fixtures are a great way to mock data for responses to routes"
5
-}

+ 0
- 22
cypress/plugins/index.js 查看文件

1
-/// <reference types="cypress" />
2
-// ***********************************************************
3
-// This example plugins/index.js can be used to load plugins
4
-//
5
-// You can change the location of this file or turn off loading
6
-// the plugins file with the 'pluginsFile' configuration option.
7
-//
8
-// You can read more here:
9
-// https://on.cypress.io/plugins-guide
10
-// ***********************************************************
11
-
12
-// This function is called when a project is opened or re-opened (e.g. due to
13
-// the project's config changing)
14
-
15
-/**
16
- * @type {Cypress.PluginConfig}
17
- */
18
-// eslint-disable-next-line no-unused-vars
19
-module.exports = (on, config) => {
20
-  // `on` is used to hook into various events Cypress emits
21
-  // `config` is the resolved Cypress config
22
-}

+ 0
- 25
cypress/support/commands.js 查看文件

1
-// ***********************************************
2
-// This example commands.js shows you how to
3
-// create various custom commands and overwrite
4
-// existing commands.
5
-//
6
-// For more comprehensive examples of custom
7
-// commands please read more here:
8
-// https://on.cypress.io/custom-commands
9
-// ***********************************************
10
-//
11
-//
12
-// -- This is a parent command --
13
-// Cypress.Commands.add('login', (email, password) => { ... })
14
-//
15
-//
16
-// -- This is a child command --
17
-// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
18
-//
19
-//
20
-// -- This is a dual command --
21
-// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
22
-//
23
-//
24
-// -- This will overwrite an existing command --
25
-// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

+ 0
- 20
cypress/support/index.js 查看文件

1
-// ***********************************************************
2
-// This example support/index.js is processed and
3
-// loaded automatically before your test files.
4
-//
5
-// This is a great place to put global configuration and
6
-// behavior that modifies Cypress.
7
-//
8
-// You can change the location of this file or turn off
9
-// automatically serving support files with the
10
-// 'supportFile' configuration option.
11
-//
12
-// You can read more here:
13
-// https://on.cypress.io/configuration
14
-// ***********************************************************
15
-
16
-// Import commands.js using ES2015 syntax:
17
-import './commands'
18
-
19
-// Alternatively you can use CommonJS syntax:
20
-// require('./commands')

+ 6
- 6
lib/shape-styles.ts 查看文件

38
 }
38
 }
39
 
39
 
40
 const dashArrays = {
40
 const dashArrays = {
41
-  [DashStyle.Solid]: () => 'none',
42
-  [DashStyle.Dashed]: (sw: number) => `${sw} ${sw * 2}`,
43
-  [DashStyle.Dotted]: (sw: number) => `0 ${sw * 1.5}`,
41
+  [DashStyle.Solid]: () => [1],
42
+  [DashStyle.Dashed]: (sw: number) => [sw * 2, sw * 4],
43
+  [DashStyle.Dotted]: (sw: number) => [0, sw * 3],
44
 }
44
 }
45
 
45
 
46
 const fontSizes = {
46
 const fontSizes = {
50
   auto: 'auto',
50
   auto: 'auto',
51
 }
51
 }
52
 
52
 
53
-function getStrokeWidth(size: SizeStyle) {
53
+export function getStrokeWidth(size: SizeStyle) {
54
   return strokeWidths[size]
54
   return strokeWidths[size]
55
 }
55
 }
56
 
56
 
57
-function getStrokeDashArray(dash: DashStyle, strokeWidth: number) {
57
+export function getStrokeDashArray(dash: DashStyle, strokeWidth: number) {
58
   return dashArrays[dash](strokeWidth)
58
   return dashArrays[dash](strokeWidth)
59
 }
59
 }
60
 
60
 
74
   const { color, size, dash, isFilled } = style
74
   const { color, size, dash, isFilled } = style
75
 
75
 
76
   const strokeWidth = getStrokeWidth(size)
76
   const strokeWidth = getStrokeWidth(size)
77
-  const strokeDasharray = getStrokeDashArray(dash, strokeWidth)
77
+  const strokeDasharray = getStrokeDashArray(dash, strokeWidth).join()
78
 
78
 
79
   return {
79
   return {
80
     stroke: strokes[color],
80
     stroke: strokes[color],

+ 211
- 140
lib/shape-utils/arrow.tsx 查看文件

1
-import { uniqueId } from 'utils/utils'
1
+import { getArcLength, lerp, uniqueId } from 'utils/utils'
2
 import vec from 'utils/vec'
2
 import vec from 'utils/vec'
3
 import {
3
 import {
4
   getSvgPathFromStroke,
4
   getSvgPathFromStroke,
7
   translateBounds,
7
   translateBounds,
8
   pointsBetween,
8
   pointsBetween,
9
 } from 'utils/utils'
9
 } from 'utils/utils'
10
-import { ArrowShape, Bounds, ShapeHandle, ShapeType } from 'types'
10
+import { ArrowShape, Bounds, DashStyle, ShapeHandle, ShapeType } from 'types'
11
 import { registerShapeUtils } from './index'
11
 import { registerShapeUtils } from './index'
12
 import { circleFromThreePoints, isAngleBetween } from 'utils/utils'
12
 import { circleFromThreePoints, isAngleBetween } from 'utils/utils'
13
 import { pointInBounds } from 'utils/bounds'
13
 import { pointInBounds } from 'utils/bounds'
16
   intersectLineSegmentBounds,
16
   intersectLineSegmentBounds,
17
 } from 'utils/intersections'
17
 } from 'utils/intersections'
18
 import { pointInCircle } from 'utils/hitTests'
18
 import { pointInCircle } from 'utils/hitTests'
19
-import { defaultStyle, getShapeStyle } from 'lib/shape-styles'
19
+import {
20
+  defaultStyle,
21
+  getShapeStyle,
22
+  getStrokeDashArray,
23
+} from 'lib/shape-styles'
20
 import getStroke from 'perfect-freehand'
24
 import getStroke from 'perfect-freehand'
25
+import React from 'react'
21
 
26
 
22
-const ctpCache = new WeakMap<ArrowShape['handles'], number[]>()
23
 const pathCache = new WeakMap<ArrowShape, string>([])
27
 const pathCache = new WeakMap<ArrowShape, string>([])
24
 
28
 
29
+// A cache for semi-expensive circles calculated from three points
25
 function getCtp(shape: ArrowShape) {
30
 function getCtp(shape: ArrowShape) {
26
-  if (!ctpCache.has(shape.handles)) {
27
-    const { start, end, bend } = shape.handles
28
-    ctpCache.set(
29
-      shape.handles,
30
-      circleFromThreePoints(start.point, end.point, bend.point)
31
-    )
32
-  }
33
-
34
-  return ctpCache.get(shape.handles)
31
+  const { start, end, bend } = shape.handles
32
+  return circleFromThreePoints(start.point, end.point, bend.point)
35
 }
33
 }
36
 
34
 
37
 const arrow = registerShapeUtils<ArrowShape>({
35
 const arrow = registerShapeUtils<ArrowShape>({
40
   create(props) {
38
   create(props) {
41
     const {
39
     const {
42
       point = [0, 0],
40
       point = [0, 0],
43
-      points = [
44
-        [0, 0],
45
-        [0, 1],
46
-      ],
47
       handles = {
41
       handles = {
48
         start: {
42
         start: {
49
           id: 'start',
43
           id: 'start',
77
       isLocked: false,
71
       isLocked: false,
78
       isHidden: false,
72
       isHidden: false,
79
       bend: 0,
73
       bend: 0,
80
-      points,
81
       handles,
74
       handles,
82
       decorations: {
75
       decorations: {
83
         start: null,
76
         start: null,
94
   },
87
   },
95
 
88
 
96
   render(shape) {
89
   render(shape) {
97
-    const { id, bend, handles } = shape
90
+    const { id, bend, handles, style } = shape
98
     const { start, end, bend: _bend } = handles
91
     const { start, end, bend: _bend } = handles
99
 
92
 
100
-    const arrowDist = vec.dist(start.point, end.point)
101
-
102
-    const showCircle = !vec.isEqual(
93
+    const isStraightLine = vec.isEqual(
103
       _bend.point,
94
       _bend.point,
104
       vec.med(start.point, end.point)
95
       vec.med(start.point, end.point)
105
     )
96
     )
106
 
97
 
107
-    const style = getShapeStyle(shape.style)
98
+    const styles = getShapeStyle(style)
108
 
99
 
109
-    let body: JSX.Element
110
-
111
-    if (showCircle) {
112
-      if (!ctpCache.has(handles)) {
113
-        ctpCache.set(
114
-          handles,
115
-          circleFromThreePoints(start.point, end.point, _bend.point)
116
-        )
117
-      }
118
-
119
-      const circle = getCtp(shape)
100
+    const strokeWidth = +styles.strokeWidth
120
 
101
 
102
+    if (isStraightLine) {
103
+      // Render a straight arrow as a freehand path.
121
       if (!pathCache.has(shape)) {
104
       if (!pathCache.has(shape)) {
122
-        renderPath(
123
-          shape,
124
-          vec.angle([circle[0], circle[1]], end.point) -
125
-            vec.angle(start.point, end.point) +
126
-            (Math.PI / 2) * (bend > 0 ? 0.98 : -0.98)
127
-        )
105
+        renderPath(shape)
128
       }
106
       }
129
 
107
 
108
+      const offset = -vec.dist(start.point, end.point) + strokeWidth
109
+
130
       const path = pathCache.get(shape)
110
       const path = pathCache.get(shape)
131
 
111
 
132
-      body = (
133
-        <>
112
+      return (
113
+        <g id={id}>
114
+          {/* Improves hit testing */}
134
           <path
115
           <path
135
-            d={getArrowArcPath(start, end, circle, bend)}
116
+            d={path}
117
+            stroke="transparent"
136
             fill="none"
118
             fill="none"
137
-            strokeWidth={(+style.strokeWidth * 1.85).toString()}
119
+            strokeWidth={Math.max(8, strokeWidth * 2)}
138
             strokeLinecap="round"
120
             strokeLinecap="round"
121
+            strokeDasharray="none"
139
           />
122
           />
140
-          <path d={path} strokeWidth={+style.strokeWidth * 1.5} />
141
-        </>
123
+          {/* Arrowshaft */}
124
+          <circle
125
+            cx={start.point[0]}
126
+            cy={start.point[1]}
127
+            r={strokeWidth}
128
+            fill={styles.stroke}
129
+            stroke="none"
130
+          />
131
+          <path
132
+            d={path}
133
+            fill="none"
134
+            strokeWidth={
135
+              strokeWidth * (style.dash === DashStyle.Solid ? 1 : 1.618)
136
+            }
137
+            strokeDashoffset={offset}
138
+            strokeLinecap="round"
139
+          />
140
+          {/* Arrowhead */}
141
+          {style.dash !== DashStyle.Solid && (
142
+            <path
143
+              d={getArrowHeadPath(shape, 0)}
144
+              strokeWidth={strokeWidth * 1.618}
145
+              strokeDasharray="none"
146
+              fill="none"
147
+            />
148
+          )}
149
+        </g>
142
       )
150
       )
143
-    } else {
144
-      if (!pathCache.has(shape)) {
145
-        renderPath(shape)
146
-      }
151
+    }
147
 
152
 
148
-      const path = pathCache.get(shape)
153
+    const circle = getCtp(shape)
149
 
154
 
150
-      body = <path d={path} />
155
+    if (!pathCache.has(shape)) {
156
+      renderPath(
157
+        shape,
158
+        vec.angle([circle[0], circle[1]], end.point) -
159
+          vec.angle(start.point, end.point) +
160
+          (Math.PI / 2) * (bend > 0 ? 0.98 : -0.98)
161
+      )
151
     }
162
     }
152
 
163
 
153
-    return <g id={id}>{body}</g>
164
+    const path = getArrowArcPath(start, end, circle, bend)
165
+
166
+    const strokeDashOffset = getStrokeDashOffsetForArc(
167
+      shape,
168
+      circle,
169
+      strokeWidth
170
+    )
171
+
172
+    return (
173
+      <g id={id}>
174
+        {/* Improves hit testing */}
175
+        <path
176
+          d={path}
177
+          stroke="transparent"
178
+          fill="none"
179
+          strokeWidth={Math.max(8, strokeWidth * 2)}
180
+          strokeLinecap="round"
181
+          strokeDasharray="none"
182
+        />
183
+        {/* Arrow Shaft */}
184
+        <circle
185
+          cx={start.point[0]}
186
+          cy={start.point[1]}
187
+          r={strokeWidth}
188
+          fill={styles.stroke}
189
+          stroke="none"
190
+        />
191
+        <path
192
+          d={path}
193
+          fill="none"
194
+          strokeWidth={strokeWidth * 1.618}
195
+          strokeLinecap="round"
196
+          strokeDashoffset={strokeDashOffset}
197
+        />
198
+        {/* Arrowhead */}
199
+        <path
200
+          d={pathCache.get(shape)}
201
+          strokeWidth={strokeWidth * 1.618}
202
+          strokeDasharray="none"
203
+          fill="none"
204
+        />
205
+      </g>
206
+    )
154
   },
207
   },
155
 
208
 
156
   rotateBy(shape, delta) {
209
   rotateBy(shape, delta) {
179
 
232
 
180
   getBounds(shape) {
233
   getBounds(shape) {
181
     if (!this.boundsCache.has(shape)) {
234
     if (!this.boundsCache.has(shape)) {
182
-      const { start, end } = shape.handles
183
-      this.boundsCache.set(shape, getBoundsFromPoints([start.point, end.point]))
235
+      const { start, bend, end } = shape.handles
236
+      this.boundsCache.set(
237
+        shape,
238
+        getBoundsFromPoints([start.point, bend.point, end.point])
239
+      )
184
     }
240
     }
185
 
241
 
186
     return translateBounds(this.boundsCache.get(shape), shape.point)
242
     return translateBounds(this.boundsCache.get(shape), shape.point)
187
   },
243
   },
188
 
244
 
189
   getRotatedBounds(shape) {
245
   getRotatedBounds(shape) {
190
-    const { start, end } = shape.handles
246
+    const { start, bend, end } = shape.handles
191
     return translateBounds(
247
     return translateBounds(
192
-      getBoundsFromPoints([start.point, end.point], shape.rotation),
248
+      getBoundsFromPoints([start.point, bend.point, end.point], shape.rotation),
193
       shape.point
249
       shape.point
194
     )
250
     )
195
   },
251
   },
200
   },
256
   },
201
 
257
 
202
   hitTest(shape, point) {
258
   hitTest(shape, point) {
203
-    const { start, end, bend } = shape.handles
259
+    const { start, end } = shape.handles
204
     if (shape.bend === 0) {
260
     if (shape.bend === 0) {
205
       return (
261
       return (
206
         vec.distanceToLineSegment(
262
         vec.distanceToLineSegment(
239
   transform(shape, bounds, { initialShape, scaleX, scaleY }) {
295
   transform(shape, bounds, { initialShape, scaleX, scaleY }) {
240
     const initialShapeBounds = this.getBounds(initialShape)
296
     const initialShapeBounds = this.getBounds(initialShape)
241
 
297
 
298
+    // let nw = initialShape.point[0] / initialShapeBounds.width
299
+    // let nh = initialShape.point[1] / initialShapeBounds.height
300
+
301
+    // shape.point = [
302
+    //   bounds.width * (scaleX < 0 ? 1 - nw : nw),
303
+    //   bounds.height * (scaleY < 0 ? 1 - nh : nh),
304
+    // ]
305
+
242
     shape.point = [bounds.minX, bounds.minY]
306
     shape.point = [bounds.minX, bounds.minY]
243
 
307
 
244
-    shape.points = shape.points.map((_, i) => {
245
-      const [x, y] = initialShape.points[i]
308
+    const handles = ['start', 'end']
309
+
310
+    handles.forEach((handle) => {
311
+      const [x, y] = initialShape.handles[handle].point
246
       let nw = x / initialShapeBounds.width
312
       let nw = x / initialShapeBounds.width
247
       let nh = y / initialShapeBounds.height
313
       let nh = y / initialShapeBounds.height
248
 
314
 
249
-      if (i === 1) {
250
-        let [x0, y0] = initialShape.points[0]
251
-        if (x0 === x) nw = 1
252
-        if (y0 === y) nh = 1
253
-      }
254
-
255
-      return [
315
+      shape.handles[handle].point = [
256
         bounds.width * (scaleX < 0 ? 1 - nw : nw),
316
         bounds.width * (scaleX < 0 ? 1 - nw : nw),
257
         bounds.height * (scaleY < 0 ? 1 - nh : nh),
317
         bounds.height * (scaleY < 0 ? 1 - nh : nh),
258
       ]
318
       ]
259
     })
319
     })
260
 
320
 
261
-    const { start, end, bend } = shape.handles
321
+    const { start, bend, end } = shape.handles
322
+
323
+    const dist = vec.dist(start.point, end.point)
324
+
325
+    const midPoint = vec.med(start.point, end.point)
326
+
327
+    const bendDist = (dist / 2) * initialShape.bend
262
 
328
 
263
-    start.point = shape.points[0]
264
-    end.point = shape.points[1]
329
+    const u = vec.uni(vec.vec(start.point, end.point))
265
 
330
 
266
-    bend.point = getBendPoint(shape)
331
+    const point = vec.add(midPoint, vec.mul(vec.per(u), bendDist))
267
 
332
 
268
-    shape.points = [shape.handles.start.point, shape.handles.end.point]
333
+    bend.point = Math.abs(bendDist) < 10 ? midPoint : point
269
 
334
 
270
     return this
335
     return this
271
   },
336
   },
279
 
344
 
280
       shape.handles[handle.id] = handle
345
       shape.handles[handle.id] = handle
281
 
346
 
282
-      if (handle.index < 2) {
283
-        shape.points[handle.index] = handle.point
284
-      }
285
-
286
       const { start, end, bend } = shape.handles
347
       const { start, end, bend } = shape.handles
287
 
348
 
288
       const dist = vec.dist(start.point, end.point)
349
       const dist = vec.dist(start.point, end.point)
327
     end.point = vec.sub(end.point, offset)
388
     end.point = vec.sub(end.point, offset)
328
     bend.point = vec.sub(bend.point, offset)
389
     bend.point = vec.sub(bend.point, offset)
329
 
390
 
391
+    shape.handles = { ...shape.handles }
392
+
330
     return this
393
     return this
331
   },
394
   },
332
 
395
 
375
     : vec.add(midPoint, vec.mul(vec.per(u), bendDist))
438
     : vec.add(midPoint, vec.mul(vec.per(u), bendDist))
376
 }
439
 }
377
 
440
 
378
-function getResizeOffset(a: Bounds, b: Bounds) {
379
-  const { minX: x0, minY: y0, width: w0, height: h0 } = a
380
-  const { minX: x1, minY: y1, width: w1, height: h1 } = b
381
-
382
-  let delta: number[]
383
-
384
-  if (h0 === h1 && w0 !== w1) {
385
-    if (x0 !== x1) {
386
-      // moving left edge, pin right edge
387
-      delta = vec.sub([x1, y1 + h1 / 2], [x0, y0 + h0 / 2])
388
-    } else {
389
-      // moving right edge, pin left edge
390
-      delta = vec.sub([x1 + w1, y1 + h1 / 2], [x0 + w0, y0 + h0 / 2])
391
-    }
392
-  } else if (h0 !== h1 && w0 === w1) {
393
-    if (y0 !== y1) {
394
-      // moving top edge, pin bottom edge
395
-      delta = vec.sub([x1 + w1 / 2, y1], [x0 + w0 / 2, y0])
396
-    } else {
397
-      // moving bottom edge, pin top edge
398
-      delta = vec.sub([x1 + w1 / 2, y1 + h1], [x0 + w0 / 2, y0 + h0])
399
-    }
400
-  } else if (x0 !== x1) {
401
-    if (y0 !== y1) {
402
-      // moving top left, pin bottom right
403
-      delta = vec.sub([x1, y1], [x0, y0])
404
-    } else {
405
-      // moving bottom left, pin top right
406
-      delta = vec.sub([x1, y1 + h1], [x0, y0 + h0])
407
-    }
408
-  } else if (y0 !== y1) {
409
-    // moving top right, pin bottom left
410
-    delta = vec.sub([x1 + w1, y1], [x0 + w0, y0])
411
-  } else {
412
-    // moving bottom right, pin top left
413
-    delta = vec.sub([x1 + w1, y1 + h1], [x0 + w0, y0 + h0])
414
-  }
415
-
416
-  return delta
417
-}
418
-
419
 function renderPath(shape: ArrowShape, endAngle = 0) {
441
 function renderPath(shape: ArrowShape, endAngle = 0) {
420
   const { style, id } = shape
442
   const { style, id } = shape
421
   const { start, end } = shape.handles
443
   const { start, end } = shape.handles
424
 
446
 
425
   const strokeWidth = +getShapeStyle(style).strokeWidth * 2
447
   const strokeWidth = +getShapeStyle(style).strokeWidth * 2
426
 
448
 
427
-  const arrowDist = vec.dist(start.point, end.point)
428
-
429
   const styles = getShapeStyle(shape.style)
449
   const styles = getShapeStyle(shape.style)
430
 
450
 
431
-  const sw = +styles.strokeWidth
432
-
433
-  const length = Math.min(arrowDist / 2, 24 + sw * 2)
434
-  const u = vec.uni(vec.vec(start.point, end.point))
435
-  const v = vec.rot(vec.mul(vec.neg(u), length), endAngle)
451
+  const sw = strokeWidth
436
 
452
 
437
   // Start
453
   // Start
438
   const a = start.point
454
   const a = start.point
439
 
455
 
456
+  // End
457
+  const b = end.point
458
+
440
   // Middle
459
   // Middle
441
   const m = vec.add(
460
   const m = vec.add(
442
     vec.lrp(start.point, end.point, 0.25 + Math.abs(getRandom()) / 2),
461
     vec.lrp(start.point, end.point, 0.25 + Math.abs(getRandom()) / 2),
443
     [getRandom() * sw, getRandom() * sw]
462
     [getRandom() * sw, getRandom() * sw]
444
   )
463
   )
445
 
464
 
446
-  // End
447
-  const b = end.point
465
+  // Left and right sides of the arrowhead
466
+  let { left: c, right: d } = getArrowHeadPoints(shape, endAngle)
448
 
467
 
449
-  // Left
450
-  let c = vec.add(
451
-    end.point,
452
-    vec.rot(v, Math.PI / 6 + (Math.PI / 8) * getRandom())
453
-  )
468
+  // Switch which side of the arrow is drawn first
469
+  if (getRandom() > 0) [c, d] = [d, c]
454
 
470
 
455
-  // Right
456
-  let d = vec.add(
457
-    end.point,
458
-    vec.rot(v, -(Math.PI / 6) + (Math.PI / 8) * getRandom())
459
-  )
460
-
461
-  if (getRandom() > 0.5) {
462
-    ;[c, d] = [d, c]
471
+  if (style.dash !== DashStyle.Solid) {
472
+    pathCache.set(
473
+      shape,
474
+      (endAngle ? ['M', c, 'L', b, d] : ['M', a, 'L', b]).join(' ')
475
+    )
476
+    return
463
   }
477
   }
464
 
478
 
465
   const points = endAngle
479
   const points = endAngle
471
         ...pointsBetween(d, b),
485
         ...pointsBetween(d, b),
472
       ]
486
       ]
473
     : [
487
     : [
474
-        // The shaft too
488
+        // The arrow shaft
475
         b,
489
         b,
476
         a,
490
         a,
477
         ...pointsBetween(a, m),
491
         ...pointsBetween(a, m),
493
 
507
 
494
   pathCache.set(shape, getSvgPathFromStroke(stroke))
508
   pathCache.set(shape, getSvgPathFromStroke(stroke))
495
 }
509
 }
510
+
511
+function getArrowHeadPath(shape: ArrowShape, endAngle = 0) {
512
+  const { end } = shape.handles
513
+  const { left, right } = getArrowHeadPoints(shape, endAngle)
514
+  return ['M', left, 'L', end.point, right].join(' ')
515
+}
516
+
517
+function getArrowHeadPoints(shape: ArrowShape, endAngle = 0) {
518
+  const { start, end } = shape.handles
519
+
520
+  const stroke = +getShapeStyle(shape.style).strokeWidth * 2
521
+
522
+  const arrowDist = vec.dist(start.point, end.point)
523
+
524
+  const arrowHeadlength = Math.min(arrowDist / 3, stroke * 4)
525
+
526
+  // Unit vector from start to end
527
+  const u = vec.uni(vec.vec(start.point, end.point))
528
+
529
+  // The end of the arrowhead wings
530
+  const v = vec.rot(vec.mul(vec.neg(u), arrowHeadlength), endAngle)
531
+
532
+  // Use the shape's random seed to create minor offsets for the angles
533
+  const getRandom = rng(shape.id)
534
+
535
+  return {
536
+    left: vec.add(
537
+      end.point,
538
+      vec.rot(v, Math.PI / 6 + (Math.PI / 8) * getRandom())
539
+    ),
540
+    right: vec.add(
541
+      end.point,
542
+      vec.rot(v, -(Math.PI / 6) + (Math.PI / 8) * getRandom())
543
+    ),
544
+  }
545
+}
546
+
547
+function getStrokeDashOffsetForArc(
548
+  shape: ArrowShape,
549
+  circle: number[],
550
+  strokeWidth: number
551
+) {
552
+  const { start, end } = shape.handles
553
+
554
+  const sweep = getArcLength(
555
+    [circle[0], circle[1]],
556
+    circle[2],
557
+    start.point,
558
+    end.point
559
+  )
560
+
561
+  return Math.abs(shape.bend) === 1
562
+    ? -strokeWidth / 2
563
+    : shape.bend < 0
564
+    ? sweep + strokeWidth
565
+    : -sweep + strokeWidth
566
+}

+ 52
- 32
lib/shape-utils/text.tsx 查看文件

2
 import vec from 'utils/vec'
2
 import vec from 'utils/vec'
3
 import { TextShape, ShapeType, FontSize, SizeStyle } from 'types'
3
 import { TextShape, ShapeType, FontSize, SizeStyle } from 'types'
4
 import { registerShapeUtils } from './index'
4
 import { registerShapeUtils } from './index'
5
-import { defaultStyle, getFontStyle, getShapeStyle } from 'lib/shape-styles'
5
+import {
6
+  defaultStyle,
7
+  getFontSize,
8
+  getFontStyle,
9
+  getShapeStyle,
10
+} from 'lib/shape-styles'
6
 import styled from 'styles'
11
 import styled from 'styles'
7
 import state from 'state'
12
 import state from 'state'
8
 import { useEffect, useRef } from 'react'
13
 import { useEffect, useRef } from 'react'
98
       state.send('FOCUSED_EDITING_SHAPE')
103
       state.send('FOCUSED_EDITING_SHAPE')
99
     }
104
     }
100
 
105
 
106
+    const fontSize = getFontSize(shape.style.size) * shape.scale
107
+    const gap = fontSize * 0.4
108
+
109
+    if (!isEditing) {
110
+      return (
111
+        <g id={id} pointerEvents="none">
112
+          {text.split('\n').map((str, i) => (
113
+            <text
114
+              key={i}
115
+              x={4}
116
+              y={4 + gap / 2 + i * (fontSize + gap)}
117
+              fontFamily="Verveine Regular"
118
+              fontStyle="normal"
119
+              fontWeight="regular"
120
+              fontSize={fontSize}
121
+              width={bounds.width}
122
+              height={bounds.height}
123
+              dominant-baseline="hanging"
124
+            >
125
+              {str}
126
+            </text>
127
+          ))}
128
+        </g>
129
+      )
130
+    }
131
+
101
     return (
132
     return (
102
       <foreignObject
133
       <foreignObject
103
         id={id}
134
         id={id}
107
         height={bounds.height}
138
         height={bounds.height}
108
         pointerEvents="none"
139
         pointerEvents="none"
109
       >
140
       >
110
-        {isEditing ? (
111
-          <StyledTextArea
112
-            ref={ref}
113
-            style={{
114
-              font,
115
-              color: styles.stroke,
116
-            }}
117
-            value={text}
118
-            tabIndex={0}
119
-            autoComplete="false"
120
-            autoCapitalize="false"
121
-            autoCorrect="false"
122
-            autoSave="false"
123
-            placeholder=""
124
-            name="text"
125
-            autoFocus={isMobile() ? true : false}
126
-            onFocus={handleFocus}
127
-            onBlur={handleBlur}
128
-            onKeyDown={handleKeyDown}
129
-            onChange={handleChange}
130
-          />
131
-        ) : (
132
-          <StyledText
133
-            style={{
134
-              font,
135
-              color: styles.stroke,
136
-            }}
137
-          >
138
-            {text}
139
-          </StyledText>
140
-        )}
141
+        <StyledTextArea
142
+          ref={ref}
143
+          style={{
144
+            font,
145
+            color: styles.stroke,
146
+          }}
147
+          value={text}
148
+          tabIndex={0}
149
+          autoComplete="false"
150
+          autoCapitalize="false"
151
+          autoCorrect="false"
152
+          autoSave="false"
153
+          placeholder=""
154
+          name="text"
155
+          autoFocus={isMobile() ? true : false}
156
+          onFocus={handleFocus}
157
+          onBlur={handleBlur}
158
+          onKeyDown={handleKeyDown}
159
+          onChange={handleChange}
160
+        />
141
       </foreignObject>
161
       </foreignObject>
142
     )
162
     )
143
   },
163
   },

+ 6
- 2
package.json 查看文件

5
   "scripts": {
5
   "scripts": {
6
     "dev": "next dev",
6
     "dev": "next dev",
7
     "build": "next build",
7
     "build": "next build",
8
-    "start": "next start"
8
+    "start": "next start",
9
+    "test": "yarn test:app",
10
+    "test:all": "yarn test:code",
11
+    "test:update": "yarn test:app --updateSnapshot --watchAll=false"
9
   },
12
   },
10
   "dependencies": {
13
   "dependencies": {
11
     "@monaco-editor/react": "^4.1.3",
14
     "@monaco-editor/react": "^4.1.3",
46
     "@types/react": "^17.0.5",
49
     "@types/react": "^17.0.5",
47
     "@types/react-dom": "^17.0.3",
50
     "@types/react-dom": "^17.0.3",
48
     "@types/uuid": "^8.3.0",
51
     "@types/uuid": "^8.3.0",
49
-    "cypress": "^7.3.0",
52
+    "babel-jest": "^27.0.2",
53
+    "jest": "^27.0.4",
50
     "monaco-editor": "^0.24.0",
54
     "monaco-editor": "^0.24.0",
51
     "typescript": "^4.2.4"
55
     "typescript": "^4.2.4"
52
   }
56
   }

+ 2
- 0
state/commands/index.ts 查看文件

19
 import rotateCcw from './rotate-ccw'
19
 import rotateCcw from './rotate-ccw'
20
 import stretch from './stretch'
20
 import stretch from './stretch'
21
 import style from './style'
21
 import style from './style'
22
+import mutate from './mutate'
22
 import toggle from './toggle'
23
 import toggle from './toggle'
23
 import transform from './transform'
24
 import transform from './transform'
24
 import transformSingle from './transform-single'
25
 import transformSingle from './transform-single'
28
 import resetBounds from './reset-bounds'
29
 import resetBounds from './reset-bounds'
29
 
30
 
30
 const commands = {
31
 const commands = {
32
+  mutate,
31
   align,
33
   align,
32
   arrow,
34
   arrow,
33
   changePage,
35
   changePage,

+ 49
- 0
state/commands/mutate.ts 查看文件

1
+import Command from './command'
2
+import history from '../history'
3
+import { Data, Shape } from 'types'
4
+import { getShapeUtils } from 'lib/shape-utils'
5
+import { getPage, updateParents } from 'utils/utils'
6
+
7
+// Used when changing the properties of one or more shapes,
8
+// without changing selection or deleting any shapes.
9
+
10
+export default function mutateShapesCommand(
11
+  data: Data,
12
+  before: Shape[],
13
+  after: Shape[],
14
+  name = 'mutate_shapes'
15
+) {
16
+  history.execute(
17
+    data,
18
+    new Command({
19
+      name,
20
+      category: 'canvas',
21
+      do(data) {
22
+        const { shapes } = getPage(data)
23
+
24
+        after.forEach((shape) => {
25
+          shapes[shape.id] = shape
26
+          getShapeUtils(shape).onSessionComplete(shape)
27
+        })
28
+
29
+        // updateParents(
30
+        //   data,
31
+        //   after.map((shape) => shape.id)
32
+        // )
33
+      },
34
+      undo(data) {
35
+        const { shapes } = getPage(data)
36
+
37
+        before.forEach((shape) => {
38
+          shapes[shape.id] = shape
39
+          getShapeUtils(shape).onSessionComplete(shape)
40
+        })
41
+
42
+        updateParents(
43
+          data,
44
+          before.map((shape) => shape.id)
45
+        )
46
+      },
47
+    })
48
+  )
49
+}

+ 2
- 14
state/commands/transform.ts 查看文件

18
       name: 'transform_shapes',
18
       name: 'transform_shapes',
19
       category: 'canvas',
19
       category: 'canvas',
20
       do(data) {
20
       do(data) {
21
-        const { type, shapeBounds } = after
21
+        const { shapeBounds } = after
22
 
22
 
23
         const { shapes } = getPage(data)
23
         const { shapes } = getPage(data)
24
 
24
 
25
         for (let id in shapeBounds) {
25
         for (let id in shapeBounds) {
26
-          const { initialShapeBounds: bounds } = after.shapeBounds[id]
27
-          const { initialShape, transformOrigin } = before.shapeBounds[id]
28
-          const shape = shapes[id]
29
-
30
-          getShapeUtils(shape)
31
-            .transform(shape, bounds, {
32
-              type,
33
-              initialShape,
34
-              transformOrigin,
35
-              scaleX,
36
-              scaleY,
37
-            })
38
-            .onSessionComplete(shape)
26
+          shapes[id] = shapeBounds[id].initialShape
39
         }
27
         }
40
 
28
 
41
         updateParents(data, Object.keys(shapeBounds))
29
         updateParents(data, Object.keys(shapeBounds))

+ 16
- 12
state/sessions/transform-session.ts 查看文件

5
 import { current, freeze } from 'immer'
5
 import { current, freeze } from 'immer'
6
 import { getShapeUtils } from 'lib/shape-utils'
6
 import { getShapeUtils } from 'lib/shape-utils'
7
 import {
7
 import {
8
+  deepClone,
8
   getBoundsCenter,
9
   getBoundsCenter,
9
   getBoundsFromPoints,
10
   getBoundsFromPoints,
10
   getCommonBounds,
11
   getCommonBounds,
103
   }
104
   }
104
 
105
 
105
   complete(data: Data) {
106
   complete(data: Data) {
106
-    if (!this.snapshot.hasUnlockedShapes) return
107
-
108
-    commands.transform(
109
-      data,
110
-      this.snapshot,
111
-      getTransformSnapshot(data, this.transformType),
112
-      this.scaleX,
113
-      this.scaleY
107
+    const { initialShapes, hasUnlockedShapes } = this.snapshot
108
+
109
+    if (!hasUnlockedShapes) return
110
+
111
+    const page = getPage(data)
112
+
113
+    const finalShapes = initialShapes.map((shape) =>
114
+      deepClone(page.shapes[shape.id])
114
     )
115
     )
116
+
117
+    commands.mutate(data, initialShapes, finalShapes)
115
   }
118
   }
116
 }
119
 }
117
 
120
 
118
 export function getTransformSnapshot(data: Data, transformType: Edge | Corner) {
121
 export function getTransformSnapshot(data: Data, transformType: Edge | Corner) {
119
-  const cData = current(data)
120
-  const { currentPageId } = cData
121
-  const page = getPage(cData)
122
+  const { currentPageId } = data
123
+  const page = getPage(data)
122
 
124
 
123
   const initialShapes = setToArray(getSelectedIds(data))
125
   const initialShapes = setToArray(getSelectedIds(data))
124
-    .flatMap((id) => getDocumentBranch(cData, id).map((id) => page.shapes[id]))
126
+    .flatMap((id) => getDocumentBranch(data, id).map((id) => page.shapes[id]))
125
     .filter((shape) => !shape.isLocked)
127
     .filter((shape) => !shape.isLocked)
128
+    .map((shape) => deepClone(shape))
126
 
129
 
127
   const hasUnlockedShapes = initialShapes.length > 0
130
   const hasUnlockedShapes = initialShapes.length > 0
128
 
131
 
151
     hasUnlockedShapes,
154
     hasUnlockedShapes,
152
     isAllAspectRatioLocked,
155
     isAllAspectRatioLocked,
153
     currentPageId,
156
     currentPageId,
157
+    initialShapes,
154
     initialBounds: commonBounds,
158
     initialBounds: commonBounds,
155
     shapeBounds: Object.fromEntries(
159
     shapeBounds: Object.fromEntries(
156
       initialShapes.map((shape) => {
160
       initialShapes.map((shape) => {

+ 1
- 2
state/state.ts 查看文件

66
     size: SizeStyle.Medium,
66
     size: SizeStyle.Medium,
67
     color: ColorStyle.Black,
67
     color: ColorStyle.Black,
68
     dash: DashStyle.Solid,
68
     dash: DashStyle.Solid,
69
-    fontSize: FontSize.Medium,
70
     isFilled: false,
69
     isFilled: false,
71
   },
70
   },
72
   activeTool: 'select',
71
   activeTool: 'select',
131
         wait: 0.01,
130
         wait: 0.01,
132
         if: 'hasSelection',
131
         if: 'hasSelection',
133
         do: 'zoomCameraToSelectionActual',
132
         do: 'zoomCameraToSelectionActual',
134
-        else: ['zoomCameraToFit', 'zoomCameraToActual'],
133
+        else: ['zoomCameraToActual'],
135
       },
134
       },
136
       on: {
135
       on: {
137
         COPIED: { if: 'hasSelection', do: 'copyToClipboard' },
136
         COPIED: { if: 'hasSelection', do: 'copyToClipboard' },

+ 7
- 2
todo.md 查看文件

47
 
47
 
48
 ## Clipboard
48
 ## Clipboard
49
 
49
 
50
-- [ ] Copy
51
-- [ ] Paste
50
+- [x] Copy
51
+- [x] Paste shapes
52
+- [ ] Paste as text
53
+
54
+## Copy to SVG
55
+
56
+- [ ] Copy to SVG

+ 1
- 6
types.ts 查看文件

16
     isToolLocked: boolean
16
     isToolLocked: boolean
17
     isPenLocked: boolean
17
     isPenLocked: boolean
18
   }
18
   }
19
-  currentStyle: ShapeStyles & TextStyles
19
+  currentStyle: ShapeStyles
20
   activeTool: ShapeType | 'select'
20
   activeTool: ShapeType | 'select'
21
   brush?: Bounds
21
   brush?: Bounds
22
   boundsRotation: number
22
   boundsRotation: number
114
   isFilled: boolean
114
   isFilled: boolean
115
 }
115
 }
116
 
116
 
117
-export type TextStyles = {
118
-  fontSize: FontSize
119
-}
120
-
121
 export interface BaseShape {
117
 export interface BaseShape {
122
   id: string
118
   id: string
123
   seed: number
119
   seed: number
180
 
176
 
181
 export interface ArrowShape extends BaseShape {
177
 export interface ArrowShape extends BaseShape {
182
   type: ShapeType.Arrow
178
   type: ShapeType.Arrow
183
-  points: number[][]
184
   handles: Record<string, ShapeHandle>
179
   handles: Record<string, ShapeHandle>
185
   bend: number
180
   bend: number
186
   decorations?: {
181
   decorations?: {

+ 2
- 2
utils/bounds.ts 查看文件

1
-import { Bounds } from "types"
1
+import { Bounds } from 'types'
2
 import {
2
 import {
3
   intersectPolygonBounds,
3
   intersectPolygonBounds,
4
   intersectPolylineBounds,
4
   intersectPolylineBounds,
5
-} from "./intersections"
5
+} from './intersections'
6
 
6
 
7
 /**
7
 /**
8
  * Get whether two bounds collide.
8
  * Get whether two bounds collide.

+ 62
- 0
utils/utils.ts 查看文件

1850
 
1850
 
1851
   return out.join('')
1851
   return out.join('')
1852
 }
1852
 }
1853
+
1854
+function getResizeOffset(a: Bounds, b: Bounds) {
1855
+  const { minX: x0, minY: y0, width: w0, height: h0 } = a
1856
+  const { minX: x1, minY: y1, width: w1, height: h1 } = b
1857
+
1858
+  let delta: number[]
1859
+
1860
+  if (h0 === h1 && w0 !== w1) {
1861
+    if (x0 !== x1) {
1862
+      // moving left edge, pin right edge
1863
+      delta = vec.sub([x1, y1 + h1 / 2], [x0, y0 + h0 / 2])
1864
+    } else {
1865
+      // moving right edge, pin left edge
1866
+      delta = vec.sub([x1 + w1, y1 + h1 / 2], [x0 + w0, y0 + h0 / 2])
1867
+    }
1868
+  } else if (h0 !== h1 && w0 === w1) {
1869
+    if (y0 !== y1) {
1870
+      // moving top edge, pin bottom edge
1871
+      delta = vec.sub([x1 + w1 / 2, y1], [x0 + w0 / 2, y0])
1872
+    } else {
1873
+      // moving bottom edge, pin top edge
1874
+      delta = vec.sub([x1 + w1 / 2, y1 + h1], [x0 + w0 / 2, y0 + h0])
1875
+    }
1876
+  } else if (x0 !== x1) {
1877
+    if (y0 !== y1) {
1878
+      // moving top left, pin bottom right
1879
+      delta = vec.sub([x1, y1], [x0, y0])
1880
+    } else {
1881
+      // moving bottom left, pin top right
1882
+      delta = vec.sub([x1, y1 + h1], [x0, y0 + h0])
1883
+    }
1884
+  } else if (y0 !== y1) {
1885
+    // moving top right, pin bottom left
1886
+    delta = vec.sub([x1 + w1, y1], [x0 + w0, y0])
1887
+  } else {
1888
+    // moving bottom right, pin top left
1889
+    delta = vec.sub([x1 + w1, y1 + h1], [x0 + w0, y0 + h0])
1890
+  }
1891
+
1892
+  return delta
1893
+}
1894
+
1895
+export function deepClone<T extends unknown[] | object>(obj: T): T {
1896
+  if (obj === null) return null
1897
+
1898
+  let clone = { ...obj }
1899
+
1900
+  Object.keys(obj).forEach(
1901
+    (key) =>
1902
+      (clone[key] =
1903
+        typeof obj[key] === 'object' ? deepClone(obj[key]) : obj[key])
1904
+  )
1905
+
1906
+  if (Array.isArray(obj)) {
1907
+    // @ts-ignore
1908
+    clone.length = obj.length
1909
+    // @ts-ignore
1910
+    return Array.from(clone) as T
1911
+  }
1912
+
1913
+  return clone
1914
+}

+ 1028
- 498
yarn.lock
文件差異過大導致無法顯示
查看文件


Loading…
取消
儲存