Преглед на файлове

Adds code editing and shape generation

main
Steve Ruiz преди 4 години
родител
ревизия
1a01c47835

+ 2
- 1
components/canvas/bounds.tsx Целия файл

@@ -3,6 +3,7 @@ import styled from "styles"
3 3
 import inputs from "state/inputs"
4 4
 import { useRef } from "react"
5 5
 import { TransformCorner, TransformEdge } from "types"
6
+import { lerp } from "utils/utils"
6 7
 
7 8
 export default function Bounds() {
8 9
   const zoom = useSelector((state) => state.data.camera.zoom)
@@ -11,7 +12,7 @@ export default function Bounds() {
11 12
 
12 13
   if (!bounds) return null
13 14
 
14
-  const { minX, minY, maxX, maxY, width, height } = bounds
15
+  let { minX, minY, maxX, maxY, width, height } = bounds
15 16
 
16 17
   const p = 4 / zoom
17 18
   const cp = p * 2

+ 35
- 2
components/canvas/shape.tsx Целия файл

@@ -8,6 +8,7 @@ function Shape({ id }: { id: string }) {
8 8
   const rGroup = useRef<SVGGElement>(null)
9 9
 
10 10
   const isHovered = useSelector((state) => state.data.hoveredId === id)
11
+
11 12
   const isSelected = useSelector((state) => state.values.selectedIds.has(id))
12 13
 
13 14
   const shape = useSelector(
@@ -87,6 +88,7 @@ const HoverIndicator = styled("path", {
87 88
   pointerEvents: "all",
88 89
   strokeLinecap: "round",
89 90
   strokeLinejoin: "round",
91
+  transform: "all .2s",
90 92
 })
91 93
 
92 94
 const StyledGroup = styled("g", {
@@ -103,14 +105,45 @@ const StyledGroup = styled("g", {
103 105
       false: {},
104 106
     },
105 107
     isHovered: {
106
-      true: {
108
+      true: {},
109
+      false: {},
110
+    },
111
+  },
112
+  compoundVariants: [
113
+    {
114
+      isSelected: true,
115
+      isHovered: true,
116
+      css: {
107 117
         [`& ${HoverIndicator}`]: {
108 118
           opacity: "1",
109 119
           stroke: "$hint",
120
+          zStrokeWidth: [8, 4],
110 121
         },
111 122
       },
112 123
     },
113
-  },
124
+    {
125
+      isSelected: true,
126
+      isHovered: false,
127
+      css: {
128
+        [`& ${HoverIndicator}`]: {
129
+          opacity: "1",
130
+          stroke: "$hint",
131
+          zStrokeWidth: [6, 3],
132
+        },
133
+      },
134
+    },
135
+    {
136
+      isSelected: false,
137
+      isHovered: true,
138
+      css: {
139
+        [`& ${HoverIndicator}`]: {
140
+          opacity: "1",
141
+          stroke: "$hint",
142
+          zStrokeWidth: [8, 4],
143
+        },
144
+      },
145
+    },
146
+  ],
114 147
 })
115 148
 
116 149
 export { Indicator, HoverIndicator }

+ 6
- 4
components/code-panel/code-as-string.ts Целия файл

@@ -1,9 +1,11 @@
1 1
 // This is the code library.
2 2
 
3 3
 export default `
4
+new Circle({
5
+  point: [200, 200],
6
+})
4 7
 
5
-// Hello world
6
-const name = "steve"
7
-const age = 93
8
-
8
+new Rectangle({
9
+  point: [400, 300],
10
+})
9 11
 `

+ 15
- 17
components/code-panel/code-panel.tsx Целия файл

@@ -1,11 +1,13 @@
1 1
 /* eslint-disable @typescript-eslint/ban-ts-comment */
2
+import styled from "styles"
3
+import { useStateDesigner } from "@state-designer/react"
2 4
 import React, { useEffect, useRef } from "react"
3
-import state, { useSelector } from "state"
4 5
 import { motion } from "framer-motion"
6
+import state, { useSelector } from "state"
5 7
 import { CodeFile } from "types"
6
-import { useStateDesigner } from "@state-designer/react"
7 8
 import CodeDocs from "./code-docs"
8 9
 import CodeEditor from "./code-editor"
10
+import { getShapesFromCode } from "lib/code/generate"
9 11
 import {
10 12
   X,
11 13
   Code,
@@ -14,9 +16,6 @@ import {
14 16
   ChevronUp,
15 17
   ChevronDown,
16 18
 } from "react-feather"
17
-import styled from "styles"
18
-
19
-// import evalCode from "lib/code"
20 19
 
21 20
 const getErrorLineAndColumn = (e: any) => {
22 21
   if ("line" in e) {
@@ -79,12 +78,13 @@ export default function CodePanel() {
79 78
       runCode(data) {
80 79
         let error = null
81 80
 
82
-        // try {
83
-        //   const { nodes, globs } = evalCode(data.code)
84
-        //   state.send("GENERATED_ITEMS", { nodes, globs })
85
-        // } catch (e) {
86
-        //   error = { message: e.message, ...getErrorLineAndColumn(e) }
87
-        // }
81
+        try {
82
+          const shapes = getShapesFromCode(data.code)
83
+          state.send("GENERATED_SHAPES_FROM_CODE", { shapes })
84
+        } catch (e) {
85
+          console.error(e)
86
+          error = { message: e.message, ...getErrorLineAndColumn(e) }
87
+        }
88 88
 
89 89
         data.error = error
90 90
       },
@@ -182,7 +182,7 @@ const PanelContainer = styled(motion.div, {
182 182
   position: "absolute",
183 183
   top: "8px",
184 184
   right: "8px",
185
-  bottom: "8px",
185
+  bottom: "48px",
186 186
   backgroundColor: "$panel",
187 187
   borderRadius: "4px",
188 188
   overflow: "hidden",
@@ -198,9 +198,7 @@ const PanelContainer = styled(motion.div, {
198 198
   variants: {
199 199
     isCollapsed: {
200 200
       true: {},
201
-      false: {
202
-        height: "400px",
203
-      },
201
+      false: {},
204 202
     },
205 203
   },
206 204
 })
@@ -240,11 +238,11 @@ const Content = styled("div", {
240 238
   display: "grid",
241 239
   gridTemplateColumns: "1fr",
242 240
   gridTemplateRows: "auto 1fr 28px",
243
-  minWidth: "100%",
241
+  height: "100%",
244 242
   width: 560,
243
+  minWidth: "100%",
245 244
   maxWidth: 560,
246 245
   overflow: "hidden",
247
-  height: "100%",
248 246
   userSelect: "none",
249 247
   pointerEvents: "all",
250 248
 })

+ 6
- 45
components/code-panel/example-code.ts Целия файл

@@ -1,47 +1,8 @@
1
-export default `// Basic nodes and globs
1
+export default `new Circle({
2
+  point: [200, 200],
3
+})
2 4
 
3
-const nodeA = new Node({
4
-  x: -100,
5
-  y: 0,
6
-});
7
-
8
-const nodeB = new Node({
9
-  x: 100,
10
-  y: 0,
11
-});
12
-
13
-const glob = new Glob({
14
-  start: nodeA,
15
-  end: nodeB,
16
-  D: { x: 0, y: 60 },
17
-  Dp: { x: 0, y: 90 },
18
-});
19
-
20
-// Something more interesting...
21
-
22
-const PI2 = Math.PI * 2,
23
-  center = { x: 0, y: 0 },
24
-  radius = 400;
25
-
26
-let prev;
27
-
28
-for (let i = 0; i < 21; i++) {
29
-  const t = i * (PI2 / 20);
30
-
31
-  const node = new Node({
32
-    x: center.x + radius * Math.sin(t),
33
-    y: center.y + radius * Math.cos(t),
34
-  });
35
-
36
-  if (prev !== undefined) {
37
-    new Glob({
38
-      start: prev,
39
-      end: node,
40
-      D: center,
41
-      Dp: center,
42
-    });
43
-  }
44
-
45
-  prev = node;
46
-}
5
+new Rectangle({
6
+  point: [400, 300],
7
+})
47 8
 `

+ 1
- 1
components/editor.tsx Целия файл

@@ -10,7 +10,7 @@ export default function Editor() {
10 10
     <>
11 11
       <Canvas />
12 12
       <StatusBar />
13
-      {/* <CodePanel /> */}
13
+      <CodePanel />
14 14
     </>
15 15
   )
16 16
 }

+ 6
- 1
lib/code/circle.ts Целия файл

@@ -7,13 +7,18 @@ export default class Circle extends CodeShape<CircleShape> {
7 7
     super({
8 8
       id: uuid(),
9 9
       type: ShapeType.Circle,
10
+      isGenerated: true,
10 11
       name: "Circle",
11 12
       parentId: "page0",
12 13
       childIndex: 0,
13 14
       point: [0, 0],
14 15
       rotation: 0,
15 16
       radius: 20,
16
-      style: {},
17
+      style: {
18
+        fill: "#777",
19
+        stroke: "#000",
20
+        strokeWidth: 1,
21
+      },
17 22
       ...props,
18 23
     })
19 24
   }

+ 24
- 0
lib/code/dot.ts Целия файл

@@ -0,0 +1,24 @@
1
+import CodeShape from "./index"
2
+import { v4 as uuid } from "uuid"
3
+import { DotShape, ShapeType } from "types"
4
+
5
+export default class Dot extends CodeShape<DotShape> {
6
+  constructor(props = {} as Partial<DotShape>) {
7
+    super({
8
+      id: uuid(),
9
+      type: ShapeType.Dot,
10
+      isGenerated: false,
11
+      name: "Dot",
12
+      parentId: "page0",
13
+      childIndex: 0,
14
+      point: [0, 0],
15
+      rotation: 0,
16
+      style: {
17
+        fill: "#777",
18
+        stroke: "#000",
19
+        strokeWidth: 1,
20
+      },
21
+      ...props,
22
+    })
23
+  }
24
+}

+ 30
- 0
lib/code/ellipse.ts Целия файл

@@ -0,0 +1,30 @@
1
+import CodeShape from "./index"
2
+import { v4 as uuid } from "uuid"
3
+import { EllipseShape, ShapeType } from "types"
4
+
5
+export default class Ellipse extends CodeShape<EllipseShape> {
6
+  constructor(props = {} as Partial<EllipseShape>) {
7
+    super({
8
+      id: uuid(),
9
+      type: ShapeType.Ellipse,
10
+      isGenerated: false,
11
+      name: "Ellipse",
12
+      parentId: "page0",
13
+      childIndex: 0,
14
+      point: [0, 0],
15
+      radiusX: 20,
16
+      radiusY: 20,
17
+      rotation: 0,
18
+      style: {
19
+        fill: "#777",
20
+        stroke: "#000",
21
+        strokeWidth: 1,
22
+      },
23
+      ...props,
24
+    })
25
+  }
26
+
27
+  get radius() {
28
+    return this.shape.radius
29
+  }
30
+}

+ 29
- 0
lib/code/generate.ts Целия файл

@@ -0,0 +1,29 @@
1
+import Rectangle from "./rectangle"
2
+import Circle from "./circle"
3
+import Ellipse from "./ellipse"
4
+import Polyline from "./polyline"
5
+import Dot from "./dot"
6
+import Line from "./line"
7
+import Vector from "./vector"
8
+import Utils from "./utils"
9
+import { codeShapes } from "./index"
10
+
11
+const scope = { Dot, Circle, Ellipse, Line, Polyline, Rectangle, Vector, Utils }
12
+
13
+/**
14
+ * Evaluate code, collecting generated shapes in the shape set. Return the
15
+ * collected shapes as an array.
16
+ * @param code
17
+ */
18
+export function getShapesFromCode(code: string) {
19
+  codeShapes.clear()
20
+
21
+  new Function(...Object.keys(scope), `${code}`)(...Object.values(scope))
22
+
23
+  const generatedShapes = Array.from(codeShapes.values()).map((instance) => {
24
+    instance.shape.isGenerated = true
25
+    return instance.shape
26
+  })
27
+
28
+  return generatedShapes
29
+}

+ 8
- 4
lib/code/index.ts Целия файл

@@ -2,16 +2,22 @@ import { Shape } from "types"
2 2
 import * as vec from "utils/vec"
3 3
 import { getShapeUtils } from "lib/shapes"
4 4
 
5
+export const codeShapes = new Set<CodeShape<Shape>>([])
6
+
7
+/**
8
+ * A base class for code shapes. Note that creating a shape adds it to the
9
+ * shape map, while deleting it removes it from the collected shapes set
10
+ */
5 11
 export default class CodeShape<T extends Shape> {
6 12
   private _shape: T
7 13
 
8 14
   constructor(props: T) {
9 15
     this._shape = props
10
-    shapeMap.add(this)
16
+    codeShapes.add(this)
11 17
   }
12 18
 
13 19
   destroy() {
14
-    shapeMap.delete(this)
20
+    codeShapes.delete(this)
15 21
   }
16 22
 
17 23
   moveTo(point: number[]) {
@@ -50,5 +56,3 @@ export default class CodeShape<T extends Shape> {
50 56
     return this.shape.rotation
51 57
   }
52 58
 }
53
-
54
-export const shapeMap = new Set<CodeShape<Shape>>([])

+ 25
- 0
lib/code/line.ts Целия файл

@@ -0,0 +1,25 @@
1
+import CodeShape from "./index"
2
+import { v4 as uuid } from "uuid"
3
+import { LineShape, ShapeType } from "types"
4
+
5
+export default class Line extends CodeShape<LineShape> {
6
+  constructor(props = {} as Partial<LineShape>) {
7
+    super({
8
+      id: uuid(),
9
+      type: ShapeType.Line,
10
+      isGenerated: false,
11
+      name: "Line",
12
+      parentId: "page0",
13
+      childIndex: 0,
14
+      point: [0, 0],
15
+      direction: [0, 0],
16
+      rotation: 0,
17
+      style: {
18
+        fill: "#777",
19
+        stroke: "#000",
20
+        strokeWidth: 1,
21
+      },
22
+      ...props,
23
+    })
24
+  }
25
+}

+ 25
- 0
lib/code/polyline.ts Целия файл

@@ -0,0 +1,25 @@
1
+import CodeShape from "./index"
2
+import { v4 as uuid } from "uuid"
3
+import { PolylineShape, ShapeType } from "types"
4
+
5
+export default class Polyline extends CodeShape<PolylineShape> {
6
+  constructor(props = {} as Partial<PolylineShape>) {
7
+    super({
8
+      id: uuid(),
9
+      type: ShapeType.Polyline,
10
+      isGenerated: false,
11
+      name: "Polyline",
12
+      parentId: "page0",
13
+      childIndex: 0,
14
+      point: [0, 0],
15
+      points: [[0, 0]],
16
+      rotation: 0,
17
+      style: {
18
+        fill: "none",
19
+        stroke: "#000",
20
+        strokeWidth: 1,
21
+      },
22
+      ...props,
23
+    })
24
+  }
25
+}

+ 7
- 2
lib/code/rectangle.ts Целия файл

@@ -7,13 +7,18 @@ export default class Rectangle extends CodeShape<RectangleShape> {
7 7
     super({
8 8
       id: uuid(),
9 9
       type: ShapeType.Rectangle,
10
+      isGenerated: true,
10 11
       name: "Rectangle",
11 12
       parentId: "page0",
12 13
       childIndex: 0,
13 14
       point: [0, 0],
14
-      size: [1, 1],
15
+      size: [100, 100],
15 16
       rotation: 0,
16
-      style: {},
17
+      style: {
18
+        fill: "#777",
19
+        stroke: "#000",
20
+        strokeWidth: 1,
21
+      },
17 22
       ...props,
18 23
     })
19 24
   }

+ 151
- 0
lib/code/utils.ts Целия файл

@@ -0,0 +1,151 @@
1
+import { Bounds } from "types"
2
+import Vector, { Point } from "./vector"
3
+
4
+export default class Utils {
5
+  static getRayRayIntersection(p0: Vector, n0: Vector, p1: Vector, n1: Vector) {
6
+    const p0e = Vector.add(p0, n0),
7
+      p1e = Vector.add(p1, n1),
8
+      m0 = (p0e.y - p0.y) / (p0e.x - p0.x),
9
+      m1 = (p1e.y - p1.y) / (p1e.x - p1.x),
10
+      b0 = p0.y - m0 * p0.x,
11
+      b1 = p1.y - m1 * p1.x,
12
+      x = (b1 - b0) / (m0 - m1),
13
+      y = m0 * x + b0
14
+
15
+    return new Vector({ x, y })
16
+  }
17
+
18
+  static getCircleTangentToPoint(
19
+    A: Point | Vector,
20
+    r0: number,
21
+    P: Point | Vector,
22
+    side: number
23
+  ) {
24
+    const v0 = Vector.cast(A)
25
+    const v1 = Vector.cast(P)
26
+    const B = Vector.lrp(v0, v1, 0.5),
27
+      r1 = Vector.dist(v0, B),
28
+      delta = Vector.sub(B, v0),
29
+      d = Vector.len(delta)
30
+
31
+    if (!(d <= r0 + r1 && d >= Math.abs(r0 - r1))) {
32
+      return
33
+    }
34
+
35
+    const a = (r0 * r0 - r1 * r1 + d * d) / (2.0 * d),
36
+      n = 1 / d,
37
+      p = Vector.add(v0, Vector.mul(delta, a * n)),
38
+      h = Math.sqrt(r0 * r0 - a * a),
39
+      k = Vector.mul(Vector.per(delta), h * n)
40
+
41
+    return side === 0 ? p.add(k) : p.sub(k)
42
+  }
43
+
44
+  static shortAngleDist(a: number, b: number) {
45
+    const max = Math.PI * 2
46
+    const da = (b - a) % max
47
+    return ((2 * da) % max) - da
48
+  }
49
+
50
+  static getSweep(C: Vector, A: Vector, B: Vector) {
51
+    return Utils.shortAngleDist(Vector.ang(C, A), Vector.ang(C, B))
52
+  }
53
+
54
+  static bez1d(a: number, b: number, c: number, d: number, t: number) {
55
+    return (
56
+      a * (1 - t) * (1 - t) * (1 - t) +
57
+      3 * b * t * (1 - t) * (1 - t) +
58
+      3 * c * t * t * (1 - t) +
59
+      d * t * t * t
60
+    )
61
+  }
62
+
63
+  static getCubicBezierBounds(
64
+    p0: Point | Vector,
65
+    c0: Point | Vector,
66
+    c1: Point | Vector,
67
+    p1: Point | Vector
68
+  ): Bounds {
69
+    // solve for x
70
+    let a = 3 * p1[0] - 9 * c1[0] + 9 * c0[0] - 3 * p0[0]
71
+    let b = 6 * p0[0] - 12 * c0[0] + 6 * c1[0]
72
+    let c = 3 * c0[0] - 3 * p0[0]
73
+    let disc = b * b - 4 * a * c
74
+    let xl = p0[0]
75
+    let xh = p0[0]
76
+
77
+    if (p1[0] < xl) xl = p1[0]
78
+    if (p1[0] > xh) xh = p1[0]
79
+
80
+    if (disc >= 0) {
81
+      const t1 = (-b + Math.sqrt(disc)) / (2 * a)
82
+      if (t1 > 0 && t1 < 1) {
83
+        const x1 = Utils.bez1d(p0[0], c0[0], c1[0], p1[0], t1)
84
+        if (x1 < xl) xl = x1
85
+        if (x1 > xh) xh = x1
86
+      }
87
+      const t2 = (-b - Math.sqrt(disc)) / (2 * a)
88
+      if (t2 > 0 && t2 < 1) {
89
+        const x2 = Utils.bez1d(p0[0], c0[0], c1[0], p1[0], t2)
90
+        if (x2 < xl) xl = x2
91
+        if (x2 > xh) xh = x2
92
+      }
93
+    }
94
+
95
+    // Solve for y
96
+    a = 3 * p1[1] - 9 * c1[1] + 9 * c0[1] - 3 * p0[1]
97
+    b = 6 * p0[1] - 12 * c0[1] + 6 * c1[1]
98
+    c = 3 * c0[1] - 3 * p0[1]
99
+    disc = b * b - 4 * a * c
100
+    let yl = p0[1]
101
+    let yh = p0[1]
102
+    if (p1[1] < yl) yl = p1[1]
103
+    if (p1[1] > yh) yh = p1[1]
104
+    if (disc >= 0) {
105
+      const t1 = (-b + Math.sqrt(disc)) / (2 * a)
106
+      if (t1 > 0 && t1 < 1) {
107
+        const y1 = Utils.bez1d(p0[1], c0[1], c1[1], p1[1], t1)
108
+        if (y1 < yl) yl = y1
109
+        if (y1 > yh) yh = y1
110
+      }
111
+      const t2 = (-b - Math.sqrt(disc)) / (2 * a)
112
+      if (t2 > 0 && t2 < 1) {
113
+        const y2 = Utils.bez1d(p0[1], c0[1], c1[1], p1[1], t2)
114
+        if (y2 < yl) yl = y2
115
+        if (y2 > yh) yh = y2
116
+      }
117
+    }
118
+
119
+    return {
120
+      minX: xl,
121
+      minY: yl,
122
+      maxX: xh,
123
+      maxY: yh,
124
+      width: Math.abs(xl - xh),
125
+      height: Math.abs(yl - yh),
126
+    }
127
+  }
128
+
129
+  static getExpandedBounds(a: Bounds, b: Bounds) {
130
+    const minX = Math.min(a.minX, b.minX),
131
+      minY = Math.min(a.minY, b.minY),
132
+      maxX = Math.max(a.maxX, b.maxX),
133
+      maxY = Math.max(a.maxY, b.maxY),
134
+      width = Math.abs(maxX - minX),
135
+      height = Math.abs(maxY - minY)
136
+
137
+    return { minX, minY, maxX, maxY, width, height }
138
+  }
139
+
140
+  static getCommonBounds(...b: Bounds[]) {
141
+    if (b.length < 2) return b[0]
142
+
143
+    let bounds = b[0]
144
+
145
+    for (let i = 1; i < b.length; i++) {
146
+      bounds = Utils.getExpandedBounds(bounds, b[i])
147
+    }
148
+
149
+    return bounds
150
+  }
151
+}

+ 476
- 0
lib/code/vector.ts Целия файл

@@ -0,0 +1,476 @@
1
+export interface VectorOptions {
2
+  x: number
3
+  y: number
4
+}
5
+
6
+export interface Point {
7
+  x: number
8
+  y: number
9
+}
10
+
11
+export default class Vector {
12
+  x = 0
13
+  y = 0
14
+
15
+  constructor(x: number, y: number)
16
+  constructor(vector: Vector, b?: undefined)
17
+  constructor(options: Point, b?: undefined)
18
+  constructor(a: VectorOptions | Vector | number, b?: number) {
19
+    if (typeof a === "number") {
20
+      this.x = a
21
+      this.y = b
22
+    } else {
23
+      const { x = 0, y = 0 } = a
24
+      this.x = x
25
+      this.y = y
26
+    }
27
+  }
28
+
29
+  set(v: Vector | Point) {
30
+    this.x = v.x
31
+    this.y = v.y
32
+  }
33
+
34
+  copy() {
35
+    return new Vector(this)
36
+  }
37
+
38
+  clone() {
39
+    return this.copy()
40
+  }
41
+
42
+  toArray() {
43
+    return [this.x, this.y]
44
+  }
45
+
46
+  add(b: Vector) {
47
+    this.x += b.x
48
+    this.y += b.y
49
+    return this
50
+  }
51
+
52
+  static add(a: Vector, b: Vector) {
53
+    const n = new Vector(a)
54
+    n.x += b.x
55
+    n.y += b.y
56
+    return n
57
+  }
58
+
59
+  sub(b: Vector) {
60
+    this.x -= b.x
61
+    this.y -= b.y
62
+    return this
63
+  }
64
+
65
+  static sub(a: Vector, b: Vector) {
66
+    const n = new Vector(a)
67
+    n.x -= b.x
68
+    n.y -= b.y
69
+    return n
70
+  }
71
+
72
+  mul(b: number): Vector
73
+  mul(b: Vector): Vector
74
+  mul(b: Vector | number) {
75
+    if (b instanceof Vector) {
76
+      this.x *= b.x
77
+      this.y *= b.y
78
+    } else {
79
+      this.x *= b
80
+      this.y *= b
81
+    }
82
+    return this
83
+  }
84
+
85
+  mulScalar(b: number) {
86
+    return this.mul(b)
87
+  }
88
+
89
+  static mulScalar(a: Vector, b: number) {
90
+    return Vector.mul(a, b)
91
+  }
92
+
93
+  static mul(a: Vector, b: number): Vector
94
+  static mul(a: Vector, b: Vector): Vector
95
+  static mul(a: Vector, b: Vector | number) {
96
+    const n = new Vector(a)
97
+    if (b instanceof Vector) {
98
+      n.x *= b.x
99
+      n.y *= b.y
100
+    } else {
101
+      n.x *= b
102
+      n.y *= b
103
+    }
104
+    return n
105
+  }
106
+
107
+  div(b: number): Vector
108
+  div(b: Vector): Vector
109
+  div(b: Vector | number) {
110
+    if (b instanceof Vector) {
111
+      if (b.x) {
112
+        this.x /= b.x
113
+      }
114
+      if (b.y) {
115
+        this.y /= b.y
116
+      }
117
+    } else {
118
+      if (b) {
119
+        this.x /= b
120
+        this.y /= b
121
+      }
122
+    }
123
+    return this
124
+  }
125
+
126
+  static div(a: Vector, b: number): Vector
127
+  static div(a: Vector, b: Vector): Vector
128
+  static div(a: Vector, b: Vector | number) {
129
+    const n = new Vector(a)
130
+    if (b instanceof Vector) {
131
+      if (b.x) n.x /= b.x
132
+      if (b.y) n.y /= b.y
133
+    } else {
134
+      if (b) {
135
+        n.x /= b
136
+        n.y /= b
137
+      }
138
+    }
139
+    return n
140
+  }
141
+
142
+  divScalar(b: number) {
143
+    return this.div(b)
144
+  }
145
+
146
+  static divScalar(a: Vector, b: number) {
147
+    return Vector.div(a, b)
148
+  }
149
+
150
+  vec(b: Vector) {
151
+    const { x, y } = this
152
+    this.x = b.x - x
153
+    this.y = b.y - y
154
+    return this
155
+  }
156
+
157
+  static vec(a: Vector, b: Vector) {
158
+    const n = new Vector(a)
159
+    n.x = b.x - a.x
160
+    n.y = b.y - a.y
161
+    return n
162
+  }
163
+
164
+  pry(b: Vector) {
165
+    return this.dpr(b) / b.len()
166
+  }
167
+
168
+  static pry(a: Vector, b: Vector) {
169
+    return a.dpr(b) / b.len()
170
+  }
171
+
172
+  dpr(b: Vector) {
173
+    return this.x * b.x + this.y * b.y
174
+  }
175
+
176
+  static dpr(a: Vector, b: Vector) {
177
+    return a.x & (b.x + a.y * b.y)
178
+  }
179
+
180
+  cpr(b: Vector) {
181
+    return this.x * b.y - b.y * this.y
182
+  }
183
+
184
+  static cpr(a: Vector, b: Vector) {
185
+    return a.x * b.y - b.y * a.y
186
+  }
187
+
188
+  tangent(b: Vector) {
189
+    return this.sub(b).uni()
190
+  }
191
+
192
+  static tangent(a: Vector, b: Vector) {
193
+    const n = new Vector(a)
194
+    return n.sub(b).uni()
195
+  }
196
+
197
+  dist2(b: Vector) {
198
+    return this.sub(b).len2()
199
+  }
200
+
201
+  static dist2(a: Vector, b: Vector) {
202
+    const n = new Vector(a)
203
+    return n.sub(b).len2()
204
+  }
205
+
206
+  dist(b: Vector) {
207
+    return Math.hypot(b.y - this.y, b.x - this.x)
208
+  }
209
+
210
+  static dist(a: Vector, b: Vector) {
211
+    const n = new Vector(a)
212
+    return Math.hypot(b.y - n.y, b.x - n.x)
213
+  }
214
+
215
+  ang(b: Vector) {
216
+    return Math.atan2(b.y - this.y, b.x - this.x)
217
+  }
218
+
219
+  static ang(a: Vector, b: Vector) {
220
+    const n = new Vector(a)
221
+    return Math.atan2(b.y - n.y, b.x - n.x)
222
+  }
223
+
224
+  med(b: Vector) {
225
+    return this.add(b).mul(0.5)
226
+  }
227
+
228
+  static med(a: Vector, b: Vector) {
229
+    const n = new Vector(a)
230
+    return n.add(b).mul(0.5)
231
+  }
232
+
233
+  rot(r: number) {
234
+    const { x, y } = this
235
+    this.x = x * Math.cos(r) - y * Math.sin(r)
236
+    this.y = x * Math.sin(r) + y * Math.cos(r)
237
+    return this
238
+  }
239
+
240
+  static rot(a: Vector, r: number) {
241
+    const n = new Vector(a)
242
+    n.x = a.x * Math.cos(r) - a.y * Math.sin(r)
243
+    n.y = a.x * Math.sin(r) + a.y * Math.cos(r)
244
+    return n
245
+  }
246
+
247
+  rotAround(b: Vector, r: number) {
248
+    const { x, y } = this
249
+    const s = Math.sin(r)
250
+    const c = Math.cos(r)
251
+
252
+    const px = x - b.x
253
+    const py = y - b.y
254
+
255
+    this.x = px * c - py * s + b.x
256
+    this.y = px * s + py * c + b.y
257
+
258
+    return this
259
+  }
260
+
261
+  static rotAround(a: Vector, b: Vector, r: number) {
262
+    const n = new Vector(a)
263
+    const s = Math.sin(r)
264
+    const c = Math.cos(r)
265
+
266
+    const px = n.x - b.x
267
+    const py = n.y - b.y
268
+
269
+    n.x = px * c - py * s + b.x
270
+    n.y = px * s + py * c + b.y
271
+
272
+    return n
273
+  }
274
+
275
+  lrp(b: Vector, t: number) {
276
+    const n = new Vector(this)
277
+    this.vec(b)
278
+      .mul(t)
279
+      .add(n)
280
+  }
281
+
282
+  static lrp(a: Vector, b: Vector, t: number) {
283
+    const n = new Vector(a)
284
+    n.vec(b)
285
+      .mul(t)
286
+      .add(a)
287
+    return n
288
+  }
289
+
290
+  nudge(b: Vector, d: number) {
291
+    this.add(b.mul(d))
292
+  }
293
+
294
+  static nudge(a: Vector, b: Vector, d: number) {
295
+    const n = new Vector(a)
296
+    return n.add(b.mul(d))
297
+  }
298
+
299
+  nudgeToward(b: Vector, d: number) {
300
+    return this.nudge(Vector.vec(this, b).uni(), d)
301
+  }
302
+
303
+  static nudgeToward(a: Vector, b: Vector, d: number) {
304
+    return Vector.nudge(a, Vector.vec(a, b).uni(), d)
305
+  }
306
+
307
+  int(b: Vector, from: number, to: number, s: number) {
308
+    const t = (Math.max(from, to) - from) / (to - from)
309
+    this.add(Vector.mul(this, 1 - t).add(Vector.mul(b, s)))
310
+    return this
311
+  }
312
+
313
+  static int(a: Vector, b: Vector, from: number, to: number, s: number) {
314
+    const n = new Vector(a)
315
+    const t = (Math.max(from, to) - from) / (to - from)
316
+    n.add(Vector.mul(a, 1 - t).add(Vector.mul(b, s)))
317
+    return n
318
+  }
319
+
320
+  equals(b: Vector) {
321
+    return this.x === b.x && this.y === b.y
322
+  }
323
+
324
+  static equals(a: Vector, b: Vector) {
325
+    return a.x === b.x && a.y === b.y
326
+  }
327
+
328
+  abs() {
329
+    this.x = Math.abs(this.x)
330
+    this.y = Math.abs(this.y)
331
+    return this
332
+  }
333
+
334
+  static abs(a: Vector) {
335
+    const n = new Vector(a)
336
+    n.x = Math.abs(n.x)
337
+    n.y = Math.abs(n.y)
338
+    return n
339
+  }
340
+
341
+  len() {
342
+    return Math.hypot(this.x, this.y)
343
+  }
344
+
345
+  static len(a: Vector) {
346
+    return Math.hypot(a.x, a.y)
347
+  }
348
+
349
+  len2() {
350
+    return this.x * this.x + this.y * this.y
351
+  }
352
+
353
+  static len2(a: Vector) {
354
+    return a.x * a.x + a.y * a.y
355
+  }
356
+
357
+  per() {
358
+    const t = this.x
359
+    this.x = this.y
360
+    this.y = -t
361
+    return this
362
+  }
363
+
364
+  static per(a: Vector) {
365
+    const n = new Vector(a)
366
+    n.x = n.y
367
+    n.y = -a.x
368
+    return n
369
+  }
370
+
371
+  neg() {
372
+    this.x *= -1
373
+    this.y *= -1
374
+    return this
375
+  }
376
+
377
+  static neg(v: Vector) {
378
+    const n = new Vector(v)
379
+    n.x *= -1
380
+    n.y *= -1
381
+    return n
382
+  }
383
+
384
+  uni() {
385
+    return this.div(this.len())
386
+  }
387
+
388
+  static uni(v: Vector) {
389
+    const n = new Vector(v)
390
+    return n.div(n.len())
391
+  }
392
+
393
+  isLeft(center: Vector, b: Vector) {
394
+    return (
395
+      (center.x - this.x) * (b.y - this.y) - (b.x - this.x) * (center.y - b.y)
396
+    )
397
+  }
398
+
399
+  static isLeft(center: Vector, a: Vector, b: Vector) {
400
+    return (center.x - a.x) * (b.y - a.y) - (b.x - a.x) * (center.y - b.y)
401
+  }
402
+
403
+  static ang3(center: Vector, a: Vector, b: Vector) {
404
+    const v1 = Vector.vec(center, a)
405
+    const v2 = Vector.vec(center, b)
406
+    return Vector.ang(v1, v2)
407
+  }
408
+
409
+  static clockwise(center: Vector, a: Vector, b: Vector) {
410
+    return Vector.isLeft(center, a, b) > 0
411
+  }
412
+
413
+  static cast(v: Point | Vector) {
414
+    return "cast" in v ? v : new Vector(v)
415
+  }
416
+
417
+  static from(v: Vector) {
418
+    return new Vector(v)
419
+  }
420
+
421
+  nearestPointOnLineThroughPoint(b: Vector, u: Vector) {
422
+    return this.clone().add(u.clone().mul(Vector.sub(this, b).pry(u)))
423
+  }
424
+
425
+  static nearestPointOnLineThroughPoint(a: Vector, b: Vector, u: Vector) {
426
+    return a.clone().add(u.clone().mul(Vector.sub(a, b).pry(u)))
427
+  }
428
+
429
+  distanceToLineThroughPoint(b: Vector, u: Vector) {
430
+    return this.dist(Vector.nearestPointOnLineThroughPoint(b, u, this))
431
+  }
432
+
433
+  static distanceToLineThroughPoint(a: Vector, b: Vector, u: Vector) {
434
+    return a.dist(Vector.nearestPointOnLineThroughPoint(b, u, a))
435
+  }
436
+
437
+  nearestPointOnLineSegment(p0: Vector, p1: Vector, clamp = true) {
438
+    return Vector.nearestPointOnLineSegment(this, p0, p1, clamp)
439
+  }
440
+
441
+  static nearestPointOnLineSegment(
442
+    a: Vector,
443
+    p0: Vector,
444
+    p1: Vector,
445
+    clamp = true
446
+  ) {
447
+    const delta = Vector.sub(p1, p0)
448
+    const length = delta.len()
449
+    const u = Vector.div(delta, length)
450
+
451
+    const pt = Vector.add(p0, Vector.mul(u, Vector.pry(Vector.sub(a, p0), u)))
452
+
453
+    if (clamp) {
454
+      const da = p0.dist(pt)
455
+      const db = p1.dist(pt)
456
+
457
+      if (db < da && da > length) return p1
458
+      if (da < db && db > length) return p0
459
+    }
460
+
461
+    return pt
462
+  }
463
+
464
+  distanceToLineSegment(p0: Vector, p1: Vector, clamp = true) {
465
+    return Vector.distanceToLineSegment(this, p0, p1, clamp)
466
+  }
467
+
468
+  static distanceToLineSegment(
469
+    a: Vector,
470
+    p0: Vector,
471
+    p1: Vector,
472
+    clamp = true
473
+  ) {
474
+    return Vector.dist(a, Vector.nearestPointOnLineSegment(a, p0, p1, clamp))
475
+  }
476
+}

+ 63
- 8
lib/shapes/circle.tsx Целия файл

@@ -1,6 +1,6 @@
1 1
 import { v4 as uuid } from "uuid"
2 2
 import * as vec from "utils/vec"
3
-import { CircleShape, ShapeType } from "types"
3
+import { CircleShape, ShapeType, TransformCorner, TransformEdge } from "types"
4 4
 import { createShape } from "./index"
5 5
 import { boundsContained } from "utils/bounds"
6 6
 import { intersectCircleBounds } from "utils/intersections"
@@ -13,6 +13,7 @@ const circle = createShape<CircleShape>({
13 13
     return {
14 14
       id: uuid(),
15 15
       type: ShapeType.Circle,
16
+      isGenerated: false,
16 17
       name: "Circle",
17 18
       parentId: "page0",
18 19
       childIndex: 0,
@@ -90,16 +91,70 @@ const circle = createShape<CircleShape>({
90 91
     return shape
91 92
   },
92 93
 
93
-  transform(shape, bounds) {
94
-    // shape.point = [bounds.minX, bounds.minY]
95
-    shape.radius = Math.min(bounds.width, bounds.height) / 2
96
-    shape.point = [
97
-      bounds.minX + bounds.width / 2 - shape.radius,
98
-      bounds.minY + bounds.height / 2 - shape.radius,
99
-    ]
94
+  transform(shape, bounds, { anchor }) {
95
+    // Set the new corner or position depending on the anchor
96
+    switch (anchor) {
97
+      case TransformCorner.TopLeft: {
98
+        shape.radius = Math.min(bounds.width, bounds.height) / 2
99
+        shape.point = [
100
+          bounds.maxX - shape.radius * 2,
101
+          bounds.maxY - shape.radius * 2,
102
+        ]
103
+        break
104
+      }
105
+      case TransformCorner.TopRight: {
106
+        shape.radius = Math.min(bounds.width, bounds.height) / 2
107
+        shape.point = [bounds.minX, bounds.maxY - shape.radius * 2]
108
+        break
109
+      }
110
+      case TransformCorner.BottomRight: {
111
+        shape.radius = Math.min(bounds.width, bounds.height) / 2
112
+        shape.point = [bounds.minX, bounds.minY]
113
+        break
114
+      }
115
+      case TransformCorner.BottomLeft: {
116
+        shape.radius = Math.min(bounds.width, bounds.height) / 2
117
+        shape.point = [bounds.maxX - shape.radius * 2, bounds.minY]
118
+        break
119
+      }
120
+      case TransformEdge.Top: {
121
+        shape.radius = bounds.height / 2
122
+        shape.point = [
123
+          bounds.minX + (bounds.width / 2 - shape.radius),
124
+          bounds.minY,
125
+        ]
126
+        break
127
+      }
128
+      case TransformEdge.Right: {
129
+        shape.radius = bounds.width / 2
130
+        shape.point = [
131
+          bounds.maxX - shape.radius * 2,
132
+          bounds.minY + (bounds.height / 2 - shape.radius),
133
+        ]
134
+        break
135
+      }
136
+      case TransformEdge.Bottom: {
137
+        shape.radius = bounds.height / 2
138
+        shape.point = [
139
+          bounds.minX + (bounds.width / 2 - shape.radius),
140
+          bounds.maxY - shape.radius * 2,
141
+        ]
142
+        break
143
+      }
144
+      case TransformEdge.Left: {
145
+        shape.radius = bounds.width / 2
146
+        shape.point = [
147
+          bounds.minX,
148
+          bounds.minY + (bounds.height / 2 - shape.radius),
149
+        ]
150
+        break
151
+      }
152
+    }
100 153
 
101 154
     return shape
102 155
   },
156
+
157
+  canTransform: true,
103 158
 })
104 159
 
105 160
 export default circle

+ 3
- 0
lib/shapes/dot.tsx Целия файл

@@ -12,6 +12,7 @@ const dot = createShape<DotShape>({
12 12
     return {
13 13
       id: uuid(),
14 14
       type: ShapeType.Dot,
15
+      isGenerated: false,
15 16
       name: "Dot",
16 17
       parentId: "page0",
17 18
       childIndex: 0,
@@ -83,6 +84,8 @@ const dot = createShape<DotShape>({
83 84
 
84 85
     return shape
85 86
   },
87
+
88
+  canTransform: false,
86 89
 })
87 90
 
88 91
 export default dot

+ 3
- 0
lib/shapes/ellipse.tsx Целия файл

@@ -13,6 +13,7 @@ const ellipse = createShape<EllipseShape>({
13 13
     return {
14 14
       id: uuid(),
15 15
       type: ShapeType.Ellipse,
16
+      isGenerated: false,
16 17
       name: "Ellipse",
17 18
       parentId: "page0",
18 19
       childIndex: 0,
@@ -103,6 +104,8 @@ const ellipse = createShape<EllipseShape>({
103 104
 
104 105
     return shape
105 106
   },
107
+
108
+  canTransform: true,
106 109
 })
107 110
 
108 111
 export default ellipse

+ 22
- 5
lib/shapes/index.tsx Целия файл

@@ -1,4 +1,12 @@
1
-import { Bounds, BoundsSnapshot, Shape, Shapes, ShapeType } from "types"
1
+import {
2
+  Bounds,
3
+  BoundsSnapshot,
4
+  Shape,
5
+  Shapes,
6
+  ShapeType,
7
+  TransformCorner,
8
+  TransformEdge,
9
+} from "types"
2 10
 import circle from "./circle"
3 11
 import dot from "./dot"
4 12
 import polyline from "./polyline"
@@ -44,10 +52,16 @@ export interface ShapeUtility<K extends Shape> {
44 52
   transform(
45 53
     this: ShapeUtility<K>,
46 54
     shape: K,
47
-    bounds: Bounds & { isFlippedX: boolean; isFlippedY: boolean },
48
-    initialShape: K,
49
-    initialShapeBounds: BoundsSnapshot,
50
-    initialBounds: Bounds
55
+    bounds: Bounds,
56
+    info: {
57
+      type: TransformEdge | TransformCorner
58
+      initialShape: K
59
+      initialShapeBounds: BoundsSnapshot
60
+      initialBounds: Bounds
61
+      isFlippedX: boolean
62
+      isFlippedY: boolean
63
+      anchor: TransformEdge | TransformCorner
64
+    }
51 65
   ): K
52 66
 
53 67
   // Apply a scale to a shape.
@@ -58,6 +72,9 @@ export interface ShapeUtility<K extends Shape> {
58 72
 
59 73
   // Render a shape to JSX.
60 74
   render(this: ShapeUtility<K>, shape: K): JSX.Element
75
+
76
+  // Whether to show transform controls when this shape is selected.
77
+  canTransform: boolean
61 78
 }
62 79
 
63 80
 // A mapping of shape types to shape utilities.

+ 8
- 1
lib/shapes/line.tsx Целия файл

@@ -12,6 +12,7 @@ const line = createShape<LineShape>({
12 12
     return {
13 13
       id: uuid(),
14 14
       type: ShapeType.Line,
15
+      isGenerated: false,
15 16
       name: "Line",
16 17
       parentId: "page0",
17 18
       childIndex: 0,
@@ -63,7 +64,11 @@ const line = createShape<LineShape>({
63 64
   },
64 65
 
65 66
   hitTestBounds(this, shape, brushBounds) {
66
-    return true
67
+    const shapeBounds = this.getBounds(shape)
68
+    return (
69
+      boundsContained(shapeBounds, brushBounds) ||
70
+      intersectCircleBounds(shape.point, 4, brushBounds).length > 0
71
+    )
67 72
   },
68 73
 
69 74
   rotate(shape) {
@@ -88,6 +93,8 @@ const line = createShape<LineShape>({
88 93
 
89 94
     return shape
90 95
   },
96
+
97
+  canTransform: false,
91 98
 })
92 99
 
93 100
 export default line

+ 10
- 3
lib/shapes/polyline.tsx Целия файл

@@ -12,6 +12,7 @@ const polyline = createShape<PolylineShape>({
12 12
     return {
13 13
       id: uuid(),
14 14
       type: ShapeType.Polyline,
15
+      isGenerated: false,
15 16
       name: "Polyline",
16 17
       parentId: "page0",
17 18
       childIndex: 0,
@@ -101,17 +102,21 @@ const polyline = createShape<PolylineShape>({
101 102
     return shape
102 103
   },
103 104
 
104
-  transform(shape, bounds, initialShape, initialShapeBounds) {
105
+  transform(
106
+    shape,
107
+    bounds,
108
+    { initialShape, initialShapeBounds, isFlippedX, isFlippedY }
109
+  ) {
105 110
     shape.points = shape.points.map((_, i) => {
106 111
       const [x, y] = initialShape.points[i]
107 112
 
108 113
       return [
109 114
         bounds.width *
110
-          (bounds.isFlippedX
115
+          (isFlippedX
111 116
             ? 1 - x / initialShapeBounds.width
112 117
             : x / initialShapeBounds.width),
113 118
         bounds.height *
114
-          (bounds.isFlippedY
119
+          (isFlippedY
115 120
             ? 1 - y / initialShapeBounds.height
116 121
             : y / initialShapeBounds.height),
117 122
       ]
@@ -120,6 +125,8 @@ const polyline = createShape<PolylineShape>({
120 125
     shape.point = [bounds.minX, bounds.minY]
121 126
     return shape
122 127
   },
128
+
129
+  canTransform: true,
123 130
 })
124 131
 
125 132
 export default polyline

+ 3
- 0
lib/shapes/ray.tsx Целия файл

@@ -12,6 +12,7 @@ const ray = createShape<RayShape>({
12 12
     return {
13 13
       id: uuid(),
14 14
       type: ShapeType.Ray,
15
+      isGenerated: false,
15 16
       name: "Ray",
16 17
       parentId: "page0",
17 18
       childIndex: 0,
@@ -82,6 +83,8 @@ const ray = createShape<RayShape>({
82 83
   transform(shape, bounds) {
83 84
     return shape
84 85
   },
86
+
87
+  canTransform: false,
85 88
 })
86 89
 
87 90
 export default ray

+ 3
- 0
lib/shapes/rectangle.tsx Целия файл

@@ -11,6 +11,7 @@ const rectangle = createShape<RectangleShape>({
11 11
     return {
12 12
       id: uuid(),
13 13
       type: ShapeType.Rectangle,
14
+      isGenerated: false,
14 15
       name: "Rectangle",
15 16
       parentId: "page0",
16 17
       childIndex: 0,
@@ -86,6 +87,8 @@ const rectangle = createShape<RectangleShape>({
86 87
 
87 88
     return shape
88 89
   },
90
+
91
+  canTransform: true,
89 92
 })
90 93
 
91 94
 export default rectangle

+ 2
- 0
state/commands/command.ts Целия файл

@@ -78,6 +78,8 @@ export default class Command extends BaseCommand<Data> {
78 78
   saveSelectionState = (data: Data) => {
79 79
     const selectedIds = new Set(data.selectedIds)
80 80
     return (data: Data) => {
81
+      data.hoveredId = undefined
82
+      data.pointedId = undefined
81 83
       data.selectedIds = selectedIds
82 84
     }
83 85
   }

+ 58
- 0
state/commands/generate-shapes.ts Целия файл

@@ -0,0 +1,58 @@
1
+import Command from "./command"
2
+import history from "../history"
3
+import { Data, Shape } from "types"
4
+import { current } from "immer"
5
+
6
+export default function setGeneratedShapes(
7
+  data: Data,
8
+  currentPageId: string,
9
+  generatedShapes: Shape[]
10
+) {
11
+  const prevGeneratedShapes = Object.values(
12
+    current(data).document.pages[currentPageId].shapes
13
+  ).filter((shape) => shape.isGenerated)
14
+
15
+  for (let shape of generatedShapes) {
16
+    data.document.pages[currentPageId].shapes[shape.id] = shape
17
+  }
18
+
19
+  history.execute(
20
+    data,
21
+    new Command({
22
+      name: "translate_shapes",
23
+      category: "canvas",
24
+      do(data) {
25
+        const { shapes } = data.document.pages[currentPageId]
26
+
27
+        data.selectedIds.clear()
28
+
29
+        // Remove previous generated shapes
30
+        for (let id in shapes) {
31
+          if (shapes[id].isGenerated) {
32
+            delete shapes[id]
33
+          }
34
+        }
35
+
36
+        // Add new generated shapes
37
+        for (let shape of generatedShapes) {
38
+          shapes[shape.id] = shape
39
+        }
40
+      },
41
+      undo(data) {
42
+        const { shapes } = data.document.pages[currentPageId]
43
+
44
+        // Remove generated shapes
45
+        for (let id in shapes) {
46
+          if (shapes[id].isGenerated) {
47
+            delete shapes[id]
48
+          }
49
+        }
50
+
51
+        // Restore previous generated shapes
52
+        for (let shape of prevGeneratedShapes) {
53
+          shapes[shape.id] = shape
54
+        }
55
+      },
56
+    })
57
+  )
58
+}

+ 4
- 3
state/commands/index.ts Целия файл

@@ -1,6 +1,7 @@
1
-import translate from "./translate-command"
2
-import transform from "./transform-command"
1
+import translate from "./translate"
2
+import transform from "./transform"
3
+import generateShapes from "./generate-shapes"
3 4
 
4
-const commands = { translate, transform }
5
+const commands = { translate, transform, generateShapes }
5 6
 
6 7
 export default commands

state/commands/transform-command.ts → state/commands/transform.ts Целия файл

@@ -1,13 +1,14 @@
1 1
 import Command from "./command"
2 2
 import history from "../history"
3
-import { Data } from "types"
3
+import { Data, TransformCorner, TransformEdge } from "types"
4 4
 import { TransformSnapshot } from "state/sessions/transform-session"
5 5
 import { getShapeUtils } from "lib/shapes"
6 6
 
7 7
 export default function translateCommand(
8 8
   data: Data,
9 9
   before: TransformSnapshot,
10
-  after: TransformSnapshot
10
+  after: TransformSnapshot,
11
+  anchor: TransformCorner | TransformEdge
11 12
 ) {
12 13
   history.execute(
13 14
     data,
@@ -15,28 +16,34 @@ export default function translateCommand(
15 16
       name: "translate_shapes",
16 17
       category: "canvas",
17 18
       do(data) {
18
-        const { shapeBounds, initialBounds, currentPageId, selectedIds } = after
19
+        const {
20
+          type,
21
+          shapeBounds,
22
+          initialBounds,
23
+          currentPageId,
24
+          selectedIds,
25
+        } = after
26
+
19 27
         const { shapes } = data.document.pages[currentPageId]
20 28
 
21 29
         selectedIds.forEach((id) => {
22 30
           const { initialShape, initialShapeBounds } = shapeBounds[id]
23 31
           const shape = shapes[id]
24 32
 
25
-          getShapeUtils(shape).transform(
26
-            shape,
27
-            {
28
-              ...initialShapeBounds,
29
-              isFlippedX: false,
30
-              isFlippedY: false,
31
-            },
33
+          getShapeUtils(shape).transform(shape, initialShapeBounds, {
34
+            type,
32 35
             initialShape,
33 36
             initialShapeBounds,
34
-            initialBounds
35
-          )
37
+            initialBounds,
38
+            isFlippedX: false,
39
+            isFlippedY: false,
40
+            anchor,
41
+          })
36 42
         })
37 43
       },
38 44
       undo(data) {
39 45
         const {
46
+          type,
40 47
           shapeBounds,
41 48
           initialBounds,
42 49
           currentPageId,
@@ -49,17 +56,15 @@ export default function translateCommand(
49 56
           const { initialShape, initialShapeBounds } = shapeBounds[id]
50 57
           const shape = shapes[id]
51 58
 
52
-          getShapeUtils(shape).transform(
53
-            shape,
54
-            {
55
-              ...initialShapeBounds,
56
-              isFlippedX: false,
57
-              isFlippedY: false,
58
-            },
59
+          getShapeUtils(shape).transform(shape, initialShapeBounds, {
60
+            type,
59 61
             initialShape,
60 62
             initialShapeBounds,
61
-            initialBounds
62
-          )
63
+            initialBounds,
64
+            isFlippedX: false,
65
+            isFlippedY: false,
66
+            anchor: type,
67
+          })
63 68
         })
64 69
       },
65 70
     })

state/commands/translate-command.ts → state/commands/translate.ts Целия файл


+ 121
- 79
state/data.ts Целия файл

@@ -9,84 +9,97 @@ export const defaultDocument: Data["document"] = {
9 9
       name: "Page 0",
10 10
       childIndex: 0,
11 11
       shapes: {
12
-        shape3: shapeUtils[ShapeType.Dot].create({
13
-          id: "shape3",
14
-          name: "Shape 3",
15
-          childIndex: 3,
16
-          point: [500, 100],
17
-          style: {
18
-            fill: "#AAA",
19
-            stroke: "#777",
20
-            strokeWidth: 1,
21
-          },
22
-        }),
23
-        shape0: shapeUtils[ShapeType.Circle].create({
24
-          id: "shape0",
25
-          name: "Shape 0",
26
-          childIndex: 1,
27
-          point: [100, 100],
28
-          radius: 50,
29
-          style: {
30
-            fill: "#AAA",
31
-            stroke: "#777",
32
-            strokeWidth: 1,
33
-          },
34
-        }),
35
-        shape5: shapeUtils[ShapeType.Ellipse].create({
36
-          id: "shape5",
37
-          name: "Shape 5",
38
-          childIndex: 5,
39
-          point: [250, 100],
40
-          radiusX: 50,
41
-          radiusY: 30,
42
-          style: {
43
-            fill: "#AAA",
44
-            stroke: "#777",
45
-            strokeWidth: 1,
46
-          },
47
-        }),
48
-        shape2: shapeUtils[ShapeType.Polyline].create({
49
-          id: "shape2",
50
-          name: "Shape 2",
51
-          childIndex: 2,
52
-          point: [200, 600],
53
-          points: [
54
-            [0, 0],
55
-            [75, 200],
56
-            [100, 50],
57
-          ],
58
-          style: {
59
-            fill: "none",
60
-            stroke: "#777",
61
-            strokeWidth: 2,
62
-            strokeLinecap: "round",
63
-            strokeLinejoin: "round",
64
-          },
65
-        }),
66
-        shape1: shapeUtils[ShapeType.Rectangle].create({
67
-          id: "shape1",
68
-          name: "Shape 1",
69
-          childIndex: 1,
70
-          point: [300, 300],
71
-          size: [200, 200],
72
-          style: {
73
-            fill: "#AAA",
74
-            stroke: "#777",
75
-            strokeWidth: 1,
76
-          },
77
-        }),
78
-        shape6: shapeUtils[ShapeType.Line].create({
79
-          id: "shape6",
80
-          name: "Shape 6",
81
-          childIndex: 1,
82
-          point: [400, 400],
83
-          direction: [0.2, 0.2],
84
-          style: {
85
-            fill: "#AAA",
86
-            stroke: "#777",
87
-            strokeWidth: 1,
88
-          },
89
-        }),
12
+        // shape3: shapeUtils[ShapeType.Dot].create({
13
+        //   id: "shape3",
14
+        //   name: "Shape 3",
15
+        //   childIndex: 3,
16
+        //   point: [400, 500],
17
+        //   style: {
18
+        //     fill: "#AAA",
19
+        //     stroke: "#777",
20
+        //     strokeWidth: 1,
21
+        //   },
22
+        // }),
23
+        // shape0: shapeUtils[ShapeType.Circle].create({
24
+        //   id: "shape0",
25
+        //   name: "Shape 0",
26
+        //   childIndex: 1,
27
+        //   point: [100, 600],
28
+        //   radius: 50,
29
+        //   style: {
30
+        //     fill: "#AAA",
31
+        //     stroke: "#777",
32
+        //     strokeWidth: 1,
33
+        //   },
34
+        // }),
35
+        // shape5: shapeUtils[ShapeType.Ellipse].create({
36
+        //   id: "shape5",
37
+        //   name: "Shape 5",
38
+        //   childIndex: 5,
39
+        //   point: [400, 600],
40
+        //   radiusX: 50,
41
+        //   radiusY: 30,
42
+        //   style: {
43
+        //     fill: "#AAA",
44
+        //     stroke: "#777",
45
+        //     strokeWidth: 1,
46
+        //   },
47
+        // }),
48
+        // shape7: shapeUtils[ShapeType.Ellipse].create({
49
+        //   id: "shape7",
50
+        //   name: "Shape 7",
51
+        //   childIndex: 7,
52
+        //   point: [100, 100],
53
+        //   radiusX: 50,
54
+        //   radiusY: 30,
55
+        //   style: {
56
+        //     fill: "#AAA",
57
+        //     stroke: "#777",
58
+        //     strokeWidth: 1,
59
+        //   },
60
+        // }),
61
+        // shape2: shapeUtils[ShapeType.Polyline].create({
62
+        //   id: "shape2",
63
+        //   name: "Shape 2",
64
+        //   childIndex: 2,
65
+        //   point: [200, 600],
66
+        //   points: [
67
+        //     [0, 0],
68
+        //     [75, 200],
69
+        //     [100, 50],
70
+        //   ],
71
+        //   style: {
72
+        //     fill: "none",
73
+        //     stroke: "#777",
74
+        //     strokeWidth: 2,
75
+        //     strokeLinecap: "round",
76
+        //     strokeLinejoin: "round",
77
+        //   },
78
+        // }),
79
+        // shape1: shapeUtils[ShapeType.Rectangle].create({
80
+        //   id: "shape1",
81
+        //   name: "Shape 1",
82
+        //   childIndex: 1,
83
+        //   point: [400, 600],
84
+        //   size: [200, 200],
85
+        //   style: {
86
+        //     fill: "#AAA",
87
+        //     stroke: "#777",
88
+        //     strokeWidth: 1,
89
+        //   },
90
+        // }),
91
+        // shape6: shapeUtils[ShapeType.Line].create({
92
+        //   id: "shape6",
93
+        //   name: "Shape 6",
94
+        //   childIndex: 1,
95
+        //   point: [400, 400],
96
+        //   direction: [0.2, 0.2],
97
+        //   style: {
98
+        //     fill: "#AAA",
99
+        //     stroke: "#777",
100
+        //     strokeWidth: 1,
101
+        //   },
102
+        // }),
90 103
       },
91 104
     },
92 105
   },
@@ -94,7 +107,36 @@ export const defaultDocument: Data["document"] = {
94 107
     file0: {
95 108
       id: "file0",
96 109
       name: "index.ts",
97
-      code: "// Hello world",
110
+      code: `
111
+new Dot({
112
+  point: [0, 0],
113
+})
114
+
115
+new Circle({
116
+  point: [200, 0],
117
+  radius: 50,
118
+})
119
+
120
+new Ellipse({
121
+  point: [400, 0],
122
+  radiusX: 50,
123
+  radiusY: 75
124
+})
125
+
126
+new Rectangle({
127
+  point: [0, 300],
128
+})
129
+
130
+new Line({
131
+  point: [200, 300],
132
+  direction: [1,0.2]
133
+})
134
+
135
+new Polyline({
136
+  point: [400, 300],
137
+  points: [[0, 200], [0,0], [200, 200], [200, 0]],
138
+})
139
+`,
98 140
     },
99 141
   },
100 142
 }

+ 43
- 26
state/sessions/transform-session.ts Целия файл

@@ -10,10 +10,12 @@ import BaseSession from "./base-session"
10 10
 import commands from "state/commands"
11 11
 import { current } from "immer"
12 12
 import { getShapeUtils } from "lib/shapes"
13
-import { getCommonBounds } from "utils/utils"
13
+import { getCommonBounds, getTransformAnchor } from "utils/utils"
14 14
 
15 15
 export default class TransformSession extends BaseSession {
16 16
   delta = [0, 0]
17
+  isFlippedX = false
18
+  isFlippedY = false
17 19
   transformType: TransformEdge | TransformCorner
18 20
   origin: number[]
19 21
   snapshot: TransformSnapshot
@@ -24,13 +26,13 @@ export default class TransformSession extends BaseSession {
24 26
 
25 27
   constructor(
26 28
     data: Data,
27
-    type: TransformCorner | TransformEdge,
29
+    transformType: TransformCorner | TransformEdge,
28 30
     point: number[]
29 31
   ) {
30 32
     super(data)
31 33
     this.origin = point
32
-    this.transformType = type
33
-    this.snapshot = getTransformSnapshot(data)
34
+    this.transformType = transformType
35
+    this.snapshot = getTransformSnapshot(data, transformType)
34 36
 
35 37
     const { minX, minY, maxX, maxY } = this.snapshot.initialBounds
36 38
 
@@ -108,8 +110,8 @@ export default class TransformSession extends BaseSession {
108 110
       height: Math.abs(b[1] - a[1]),
109 111
     }
110 112
 
111
-    const isFlippedX = b[0] < a[0]
112
-    const isFlippedY = b[1] < a[1]
113
+    this.isFlippedX = b[0] < a[0]
114
+    this.isFlippedY = b[1] < a[1]
113 115
 
114 116
     // Now work backward to calculate a new bounding box for each of the shapes.
115 117
 
@@ -118,8 +120,10 @@ export default class TransformSession extends BaseSession {
118 120
       const { nx, nmx, nw, ny, nmy, nh } = initialShapeBounds
119 121
       const shape = shapes[id]
120 122
 
121
-      const minX = newBounds.minX + (isFlippedX ? nmx : nx) * newBounds.width
122
-      const minY = newBounds.minY + (isFlippedY ? nmy : ny) * newBounds.height
123
+      const minX =
124
+        newBounds.minX + (this.isFlippedX ? nmx : nx) * newBounds.width
125
+      const minY =
126
+        newBounds.minY + (this.isFlippedY ? nmy : ny) * newBounds.height
123 127
       const width = nw * newBounds.width
124 128
       const height = nh * newBounds.height
125 129
 
@@ -130,8 +134,8 @@ export default class TransformSession extends BaseSession {
130 134
         maxY: minY + height,
131 135
         width,
132 136
         height,
133
-        isFlippedX,
134
-        isFlippedY,
137
+        isFlippedX: this.isFlippedX,
138
+        isFlippedY: this.isFlippedY,
135 139
       }
136 140
 
137 141
       // Pass the new data to the shape's transform utility for mutation.
@@ -139,13 +143,19 @@ export default class TransformSession extends BaseSession {
139 143
       // however some shapes (e.g. those with internal points) will need more
140 144
       // data here too.
141 145
 
142
-      getShapeUtils(shape).transform(
143
-        shape,
144
-        newShapeBounds,
146
+      getShapeUtils(shape).transform(shape, newShapeBounds, {
147
+        type: this.transformType,
145 148
         initialShape,
146 149
         initialShapeBounds,
147
-        initialBounds
148
-      )
150
+        initialBounds,
151
+        isFlippedX: this.isFlippedX,
152
+        isFlippedY: this.isFlippedY,
153
+        anchor: getTransformAnchor(
154
+          this.transformType,
155
+          this.isFlippedX,
156
+          this.isFlippedY
157
+        ),
158
+      })
149 159
     })
150 160
   }
151 161
 
@@ -163,26 +173,32 @@ export default class TransformSession extends BaseSession {
163 173
       const shape = shapes.shapes[id]
164 174
       const { initialShape, initialShapeBounds } = shapeBounds[id]
165 175
 
166
-      getShapeUtils(shape).transform(
167
-        shape,
168
-        {
169
-          ...initialShapeBounds,
170
-          isFlippedX: false,
171
-          isFlippedY: false,
172
-        },
176
+      getShapeUtils(shape).transform(shape, initialShapeBounds, {
177
+        type: this.transformType,
173 178
         initialShape,
174 179
         initialShapeBounds,
175
-        initialBounds
176
-      )
180
+        initialBounds,
181
+        isFlippedX: false,
182
+        isFlippedY: false,
183
+        anchor: getTransformAnchor(this.transformType, false, false),
184
+      })
177 185
     })
178 186
   }
179 187
 
180 188
   complete(data: Data) {
181
-    commands.transform(data, this.snapshot, getTransformSnapshot(data))
189
+    commands.transform(
190
+      data,
191
+      this.snapshot,
192
+      getTransformSnapshot(data, this.transformType),
193
+      getTransformAnchor(this.transformType, false, false)
194
+    )
182 195
   }
183 196
 }
184 197
 
185
-export function getTransformSnapshot(data: Data) {
198
+export function getTransformSnapshot(
199
+  data: Data,
200
+  transformType: TransformEdge | TransformCorner
201
+) {
186 202
   const {
187 203
     document: { pages },
188 204
     selectedIds,
@@ -206,6 +222,7 @@ export function getTransformSnapshot(data: Data) {
206 222
   // positions of the shape's bounds within the common bounds shape.
207 223
   return {
208 224
     currentPageId,
225
+    type: transformType,
209 226
     initialBounds: bounds,
210 227
     selectedIds: new Set(selectedIds),
211 228
     shapeBounds: Object.fromEntries(

+ 25
- 11
state/state.ts Целия файл

@@ -1,11 +1,12 @@
1 1
 import { createSelectorHook, createState } from "@state-designer/react"
2 2
 import { clamp, getCommonBounds, screenToWorld } from "utils/utils"
3 3
 import * as vec from "utils/vec"
4
-import { Data, PointerInfo, TransformCorner, TransformEdge } from "types"
4
+import { Data, PointerInfo, Shape, TransformCorner, TransformEdge } from "types"
5 5
 import { defaultDocument } from "./data"
6 6
 import { getShapeUtils } from "lib/shapes"
7 7
 import history from "state/history"
8 8
 import * as Sessions from "./sessions"
9
+import commands from "./commands"
9 10
 
10 11
 const initialData: Data = {
11 12
   isReadOnly: false,
@@ -41,6 +42,9 @@ const state = createState({
41 42
       on: {
42 43
         UNDO: { do: "undo" },
43 44
         REDO: { do: "redo" },
45
+        GENERATED_SHAPES_FROM_CODE: "setGeneratedShapes",
46
+        INCREASED_CODE_FONT_SIZE: "increaseCodeFontSize",
47
+        DECREASED_CODE_FONT_SIZE: "decreaseCodeFontSize",
44 48
       },
45 49
       initial: "notPointing",
46 50
       states: {
@@ -156,10 +160,6 @@ const state = createState({
156 160
       const shape =
157 161
         data.document.pages[data.currentPageId].shapes[payload.target]
158 162
 
159
-      console.log(
160
-        getShapeUtils(shape).hitTest(shape, screenToWorld(payload.point, data))
161
-      )
162
-
163 163
       return getShapeUtils(shape).hitTest(
164 164
         shape,
165 165
         screenToWorld(payload.point, data)
@@ -181,6 +181,17 @@ const state = createState({
181 181
       history.redo(data)
182 182
     },
183 183
 
184
+    // Code
185
+    setGeneratedShapes(data, payload: { shapes: Shape[] }) {
186
+      commands.generateShapes(data, data.currentPageId, payload.shapes)
187
+    },
188
+    increaseCodeFontSize(data) {
189
+      data.settings.fontSize++
190
+    },
191
+    decreaseCodeFontSize(data) {
192
+      data.settings.fontSize--
193
+    },
194
+
184 195
     // Sessions
185 196
     cancelSession(data) {
186 197
       session.cancel(data)
@@ -287,15 +298,18 @@ const state = createState({
287 298
         document: { pages },
288 299
       } = data
289 300
 
301
+      const shapes = Array.from(selectedIds.values()).map(
302
+        (id) => pages[currentPageId].shapes[id]
303
+      )
304
+
290 305
       if (selectedIds.size === 0) return null
291 306
 
307
+      if (selectedIds.size === 1 && !getShapeUtils(shapes[0]).canTransform) {
308
+        return null
309
+      }
310
+
292 311
       return getCommonBounds(
293
-        ...Array.from(selectedIds.values())
294
-          .map((id) => {
295
-            const shape = pages[currentPageId].shapes[id]
296
-            return getShapeUtils(shape).getBounds(shape)
297
-          })
298
-          .filter(Boolean)
312
+        ...shapes.map((shape) => getShapeUtils(shape).getBounds(shape))
299 313
       )
300 314
     },
301 315
   },

+ 1
- 0
types.ts Целия файл

@@ -56,6 +56,7 @@ export interface BaseShape {
56 56
   type: ShapeType
57 57
   parentId: string
58 58
   childIndex: number
59
+  isGenerated: boolean
59 60
   name: string
60 61
   point: number[]
61 62
   rotation: number

+ 55
- 1
utils/utils.ts Целия файл

@@ -1,4 +1,4 @@
1
-import { Data, Bounds } from "types"
1
+import { Data, Bounds, TransformEdge, TransformCorner } from "types"
2 2
 import * as svg from "./svg"
3 3
 import * as vec from "./vec"
4 4
 
@@ -891,3 +891,57 @@ export function getKeyboardEventInfo(e: KeyboardEvent | React.KeyboardEvent) {
891 891
 export function isDarwin() {
892 892
   return /Mac|iPod|iPhone|iPad/.test(window.navigator.platform)
893 893
 }
894
+
895
+export function getTransformAnchor(
896
+  type: TransformEdge | TransformCorner,
897
+  isFlippedX: boolean,
898
+  isFlippedY: boolean
899
+) {
900
+  let anchor: TransformCorner | TransformEdge = type
901
+
902
+  // Change corner anchors if flipped
903
+  switch (type) {
904
+    case TransformCorner.TopLeft: {
905
+      if (isFlippedX && isFlippedY) {
906
+        anchor = TransformCorner.BottomRight
907
+      } else if (isFlippedX) {
908
+        anchor = TransformCorner.TopRight
909
+      } else if (isFlippedY) {
910
+        anchor = TransformCorner.BottomLeft
911
+      }
912
+      break
913
+    }
914
+    case TransformCorner.TopRight: {
915
+      if (isFlippedX && isFlippedY) {
916
+        anchor = TransformCorner.BottomLeft
917
+      } else if (isFlippedX) {
918
+        anchor = TransformCorner.TopLeft
919
+      } else if (isFlippedY) {
920
+        anchor = TransformCorner.BottomRight
921
+      }
922
+      break
923
+    }
924
+    case TransformCorner.BottomRight: {
925
+      if (isFlippedX && isFlippedY) {
926
+        anchor = TransformCorner.TopLeft
927
+      } else if (isFlippedX) {
928
+        anchor = TransformCorner.BottomLeft
929
+      } else if (isFlippedY) {
930
+        anchor = TransformCorner.TopRight
931
+      }
932
+      break
933
+    }
934
+    case TransformCorner.BottomLeft: {
935
+      if (isFlippedX && isFlippedY) {
936
+        anchor = TransformCorner.TopRight
937
+      } else if (isFlippedX) {
938
+        anchor = TransformCorner.BottomRight
939
+      } else if (isFlippedY) {
940
+        anchor = TransformCorner.TopLeft
941
+      }
942
+      break
943
+    }
944
+  }
945
+
946
+  return anchor
947
+}

Loading…
Отказ
Запис