Browse Source

Adds groups

main
Steve Ruiz 4 years ago
parent
commit
506eecc95f

+ 3
- 1
components/canvas/bounds/bounding-box.tsx View File

@@ -3,6 +3,7 @@ import { Edge, Corner } from 'types'
3 3
 import { useSelector } from 'state'
4 4
 import {
5 5
   deepCompareArrays,
6
+  getBoundsCenter,
6 7
   getCurrentCamera,
7 8
   getPage,
8 9
   getSelectedShapes,
@@ -58,7 +59,8 @@ export default function Bounds() {
58 59
         rotate(${rotation * (180 / Math.PI)}, 
59 60
         ${(bounds.minX + bounds.maxX) / 2}, 
60 61
         ${(bounds.minY + bounds.maxY) / 2})
61
-        translate(${bounds.minX},${bounds.minY})`}
62
+        translate(${bounds.minX},${bounds.minY})
63
+        rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)`}
62 64
     >
63 65
       <CenterHandle bounds={bounds} isLocked={isAllLocked} />
64 66
       {!isAllLocked && (

+ 2
- 1
components/canvas/bounds/bounds-bg.tsx View File

@@ -66,7 +66,8 @@ export default function BoundsBg() {
66 66
         rotate(${rotation * (180 / Math.PI)}, 
67 67
         ${(bounds.minX + bounds.maxX) / 2}, 
68 68
         ${(bounds.minY + bounds.maxY) / 2})
69
-        translate(${bounds.minX},${bounds.minY})`}
69
+        translate(${bounds.minX},${bounds.minY})
70
+        rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)`}
70 71
       onPointerDown={handlePointerDown}
71 72
       onPointerUp={handlePointerUp}
72 73
     />

+ 1
- 1
components/canvas/canvas.tsx View File

@@ -62,7 +62,7 @@ export default function Canvas() {
62 62
         <g ref={rGroup}>
63 63
           <BoundsBg />
64 64
           <Page />
65
-          <Selected />
65
+          {/* <Selected /> */}
66 66
           <Bounds />
67 67
           <Handles />
68 68
           <Brush />

+ 11
- 1
components/canvas/page.tsx View File

@@ -1,4 +1,5 @@
1 1
 import { useSelector } from 'state'
2
+import { GroupShape } from 'types'
2 3
 import { deepCompareArrays, getPage } from 'utils/utils'
3 4
 import Shape from './shape'
4 5
 
@@ -8,9 +9,12 @@ on the current page. Kind of expensive but only happens
8 9
 here; and still cheaper than any other pattern I've found.
9 10
 */
10 11
 
12
+const noOffset = [0, 0]
13
+
11 14
 export default function Page() {
12 15
   const currentPageShapeIds = useSelector(({ data }) => {
13 16
     return Object.values(getPage(data).shapes)
17
+      .filter((shape) => shape.parentId === data.currentPageId)
14 18
       .sort((a, b) => a.childIndex - b.childIndex)
15 19
       .map((shape) => shape.id)
16 20
   }, deepCompareArrays)
@@ -20,7 +24,13 @@ export default function Page() {
20 24
   return (
21 25
     <g pointerEvents={isSelecting ? 'all' : 'none'}>
22 26
       {currentPageShapeIds.map((shapeId) => (
23
-        <Shape key={shapeId} id={shapeId} isSelecting={isSelecting} />
27
+        <Shape
28
+          key={shapeId}
29
+          id={shapeId}
30
+          isSelecting={isSelecting}
31
+          parentPoint={noOffset}
32
+          parentRotation={0}
33
+        />
24 34
       ))}
25 35
     </g>
26 36
   )

+ 19
- 18
components/canvas/selected.tsx View File

@@ -4,11 +4,11 @@ import { deepCompareArrays, getPage } from 'utils/utils'
4 4
 import { getShapeUtils } from 'lib/shape-utils'
5 5
 import useShapeEvents from 'hooks/useShapeEvents'
6 6
 import { memo, useRef } from 'react'
7
+import { ShapeType } from 'types'
8
+import * as vec from 'utils/vec'
7 9
 
8 10
 export default function Selected() {
9
-  const selectedIds = useSelector((s) => s.data.selectedIds)
10
-
11
-  const currentPageShapeIds = useSelector(({ data }) => {
11
+  const currentSelectedShapeIds = useSelector(({ data }) => {
12 12
     return Array.from(data.selectedIds.values())
13 13
   }, deepCompareArrays)
14 14
 
@@ -18,32 +18,33 @@ export default function Selected() {
18 18
 
19 19
   return (
20 20
     <g>
21
-      {currentPageShapeIds.map((id) => (
22
-        <ShapeOutline key={id} id={id} isSelected={selectedIds.has(id)} />
21
+      {currentSelectedShapeIds.map((id) => (
22
+        <ShapeOutline key={id} id={id} />
23 23
       ))}
24 24
     </g>
25 25
   )
26 26
 }
27 27
 
28
-export const ShapeOutline = memo(function ShapeOutline({
29
-  id,
30
-  isSelected,
31
-}: {
32
-  id: string
33
-  isSelected: boolean
34
-}) {
28
+export const ShapeOutline = memo(function ShapeOutline({ id }: { id: string }) {
35 29
   const rIndicator = useRef<SVGUseElement>(null)
36 30
 
37
-  const shape = useSelector(({ data }) => getPage(data).shapes[id])
31
+  const shape = useSelector((s) => getPage(s.data).shapes[id])
38 32
 
39
-  const events = useShapeEvents(id, rIndicator)
33
+  const events = useShapeEvents(id, shape?.type === ShapeType.Group, rIndicator)
40 34
 
41 35
   if (!shape) return null
42 36
 
37
+  // This needs computation from state, similar to bounds, in order
38
+  // to handle parent rotation.
39
+
40
+  const center = getShapeUtils(shape).getCenter(shape)
41
+  const bounds = getShapeUtils(shape).getBounds(shape)
42
+
43 43
   const transform = `
44
-    rotate(${shape.rotation * (180 / Math.PI)},
45
-    ${getShapeUtils(shape).getCenter(shape)})
46
-    translate(${shape.point})
44
+  rotate(${shape.rotation * (180 / Math.PI)}, 
45
+  ${center})
46
+  translate(${bounds.minX},${bounds.minY})
47
+  rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)
47 48
   `
48 49
 
49 50
   return (
@@ -59,7 +60,7 @@ export const ShapeOutline = memo(function ShapeOutline({
59 60
 })
60 61
 
61 62
 const SelectIndicator = styled('path', {
62
-  zStrokeWidth: 3,
63
+  zStrokeWidth: 1,
63 64
   strokeLineCap: 'round',
64 65
   strokeLinejoin: 'round',
65 66
   stroke: '$selected',

+ 46
- 30
components/canvas/shape.tsx View File

@@ -3,16 +3,24 @@ import { useSelector } from 'state'
3 3
 import styled from 'styles'
4 4
 import { getShapeUtils } from 'lib/shape-utils'
5 5
 import { getPage } from 'utils/utils'
6
-import { DashStyle, ShapeStyles } from 'types'
6
+import { ShapeType } from 'types'
7 7
 import useShapeEvents from 'hooks/useShapeEvents'
8
+import * as vec from 'utils/vec'
8 9
 import { getShapeStyle } from 'lib/shape-styles'
9 10
 
10
-function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
11
+interface ShapeProps {
12
+  id: string
13
+  isSelecting: boolean
14
+  parentPoint: number[]
15
+  parentRotation: number
16
+}
17
+
18
+function Shape({ id, isSelecting, parentPoint, parentRotation }: ShapeProps) {
11 19
   const shape = useSelector(({ data }) => getPage(data).shapes[id])
12 20
 
13 21
   const rGroup = useRef<SVGGElement>(null)
14 22
 
15
-  const events = useShapeEvents(id, rGroup)
23
+  const events = useShapeEvents(id, shape?.type === ShapeType.Group, rGroup)
16 24
 
17 25
   // This is a problem with deleted shapes. The hooks in this component
18 26
   // may sometimes run before the hook in the Page component, which means
@@ -20,41 +28,45 @@ function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
20 28
   // detects the change and pulls this component.
21 29
   if (!shape) return null
22 30
 
31
+  const isGroup = shape.type === ShapeType.Group
32
+
23 33
   const center = getShapeUtils(shape).getCenter(shape)
24 34
 
25 35
   const transform = `
26
-  rotate(${shape.rotation * (180 / Math.PI)}, ${center})
27
-  translate(${shape.point})
36
+  rotate(${shape.rotation * (180 / Math.PI)}, ${vec.sub(center, parentPoint)})
37
+  translate(${vec.sub(shape.point, parentPoint)})
28 38
   `
29 39
 
30 40
   const style = getShapeStyle(shape.style)
31 41
 
32 42
   return (
33
-    <StyledGroup ref={rGroup} transform={transform}>
34
-      {isSelecting && (
35
-        <HoverIndicator
36
-          as="use"
37
-          href={'#' + id}
38
-          strokeWidth={+style.strokeWidth + 4}
39
-          variant={getShapeUtils(shape).canStyleFill ? 'filled' : 'hollow'}
40
-          {...events}
41
-        />
42
-      )}
43
-      {!shape.isHidden && <RealShape id={id} style={style} />}
44
-    </StyledGroup>
43
+    <>
44
+      <StyledGroup ref={rGroup} transform={transform}>
45
+        {isSelecting && !isGroup && (
46
+          <HoverIndicator
47
+            as="use"
48
+            href={'#' + id}
49
+            strokeWidth={+style.strokeWidth + 4}
50
+            variant={getShapeUtils(shape).canStyleFill ? 'filled' : 'hollow'}
51
+            {...events}
52
+          />
53
+        )}
54
+        {!shape.isHidden && <StyledShape as="use" href={'#' + id} {...style} />}
55
+        {isGroup &&
56
+          shape.children.map((shapeId) => (
57
+            <Shape
58
+              key={shapeId}
59
+              id={shapeId}
60
+              isSelecting={isSelecting}
61
+              parentPoint={shape.point}
62
+              parentRotation={shape.rotation}
63
+            />
64
+          ))}
65
+      </StyledGroup>
66
+    </>
45 67
   )
46 68
 }
47 69
 
48
-const RealShape = memo(function RealShape({
49
-  id,
50
-  style,
51
-}: {
52
-  id: string
53
-  style: ReturnType<typeof getShapeStyle>
54
-}) {
55
-  return <StyledShape as="use" href={'#' + id} {...style} />
56
-})
57
-
58 70
 const StyledShape = styled('path', {
59 71
   strokeLinecap: 'round',
60 72
   strokeLinejoin: 'round',
@@ -109,18 +121,18 @@ const StyledGroup = styled('g', {
109 121
   },
110 122
 })
111 123
 
112
-function Label({ text }: { text: string }) {
124
+function Label({ children }: { children: React.ReactNode }) {
113 125
   return (
114 126
     <text
115 127
       y={4}
116 128
       x={4}
117
-      fontSize={18}
129
+      fontSize={12}
118 130
       fill="black"
119 131
       stroke="none"
120 132
       alignmentBaseline="text-before-edge"
121 133
       pointerEvents="none"
122 134
     >
123
-      {text}
135
+      {children}
124 136
     </text>
125 137
   )
126 138
 }
@@ -128,3 +140,7 @@ function Label({ text }: { text: string }) {
128 140
 export { HoverIndicator }
129 141
 
130 142
 export default memo(Shape)
143
+
144
+function pp(n: number[]) {
145
+  return '[' + n.map((v) => v.toFixed(1)).join(', ') + ']'
146
+}

+ 10
- 0
hooks/useKeyboardEvents.ts View File

@@ -122,6 +122,16 @@ export default function useKeyboardEvents() {
122 122
           state.send('DELETED', getKeyboardEventInfo(e))
123 123
           break
124 124
         }
125
+        case 'g': {
126
+          if (metaKey(e)) {
127
+            if (e.shiftKey) {
128
+              state.send('UNGROUPED', getKeyboardEventInfo(e))
129
+            } else {
130
+              state.send('GROUPED', getKeyboardEventInfo(e))
131
+            }
132
+          }
133
+          break
134
+        }
125 135
         case 's': {
126 136
           if (metaKey(e)) {
127 137
             state.send('SAVED', getKeyboardEventInfo(e))

+ 25
- 7
hooks/useShapeEvents.ts View File

@@ -1,17 +1,23 @@
1
-import { MutableRefObject, useCallback } from 'react'
1
+import { MutableRefObject, useCallback, useRef } from 'react'
2 2
 import state from 'state'
3 3
 import inputs from 'state/inputs'
4 4
 
5 5
 export default function useShapeEvents(
6 6
   id: string,
7
+  isGroup: boolean,
7 8
   rGroup: MutableRefObject<SVGElement>
8 9
 ) {
9 10
   const handlePointerDown = useCallback(
10 11
     (e: React.PointerEvent) => {
12
+      if (isGroup) return
11 13
       if (!inputs.canAccept(e.pointerId)) return
12
-      // e.stopPropagation()
14
+      e.stopPropagation()
13 15
       rGroup.current.setPointerCapture(e.pointerId)
14
-      state.send('POINTED_SHAPE', inputs.pointerDown(e, id))
16
+      if (inputs.isDoubleClick()) {
17
+        state.send('DOUBLE_POINTED_SHAPE', inputs.pointerDown(e, id))
18
+      } else {
19
+        state.send('POINTED_SHAPE', inputs.pointerDown(e, id))
20
+      }
15 21
     },
16 22
     [id]
17 23
   )
@@ -19,7 +25,7 @@ export default function useShapeEvents(
19 25
   const handlePointerUp = useCallback(
20 26
     (e: React.PointerEvent) => {
21 27
       if (!inputs.canAccept(e.pointerId)) return
22
-      // e.stopPropagation()
28
+      e.stopPropagation()
23 29
       rGroup.current.releasePointerCapture(e.pointerId)
24 30
       state.send('STOPPED_POINTING', inputs.pointerUp(e))
25 31
     },
@@ -29,7 +35,11 @@ export default function useShapeEvents(
29 35
   const handlePointerEnter = useCallback(
30 36
     (e: React.PointerEvent) => {
31 37
       if (!inputs.canAccept(e.pointerId)) return
32
-      state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id))
38
+      if (isGroup) {
39
+        state.send('HOVERED_GROUP', inputs.pointerEnter(e, id))
40
+      } else {
41
+        state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id))
42
+      }
33 43
     },
34 44
     [id]
35 45
   )
@@ -37,7 +47,11 @@ export default function useShapeEvents(
37 47
   const handlePointerMove = useCallback(
38 48
     (e: React.PointerEvent) => {
39 49
       if (!inputs.canAccept(e.pointerId)) return
40
-      state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id))
50
+      if (isGroup) {
51
+        state.send('MOVED_OVER_GROUP', inputs.pointerEnter(e, id))
52
+      } else {
53
+        state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id))
54
+      }
41 55
     },
42 56
     [id]
43 57
   )
@@ -45,7 +59,11 @@ export default function useShapeEvents(
45 59
   const handlePointerLeave = useCallback(
46 60
     (e: React.PointerEvent) => {
47 61
       if (!inputs.canAccept(e.pointerId)) return
48
-      state.send('UNHOVERED_SHAPE', { target: id })
62
+      if (isGroup) {
63
+        state.send('UNHOVERED_GROUP', { target: id })
64
+      } else {
65
+        state.send('UNHOVERED_SHAPE', { target: id })
66
+      }
49 67
     },
50 68
     [id]
51 69
   )

+ 1
- 1
lib/shape-utils/arrow.tsx View File

@@ -248,7 +248,7 @@ const arrow = registerShapeUtils<ArrowShape>({
248 248
     return this
249 249
   },
250 250
 
251
-  onHandleMove(shape, handles) {
251
+  onHandleChange(shape, handles) {
252 252
     for (let id in handles) {
253 253
       const handle = handles[id]
254 254
 

+ 193
- 0
lib/shape-utils/group.tsx View File

@@ -0,0 +1,193 @@
1
+import { v4 as uuid } from 'uuid'
2
+import * as vec from 'utils/vec'
3
+import {
4
+  GroupShape,
5
+  RectangleShape,
6
+  ShapeType,
7
+  Bounds,
8
+  Corner,
9
+  Edge,
10
+} from 'types'
11
+import { getShapeUtils, registerShapeUtils } from './index'
12
+import {
13
+  getBoundsCenter,
14
+  getCommonBounds,
15
+  getRotatedCorners,
16
+  rotateBounds,
17
+  translateBounds,
18
+} from 'utils/utils'
19
+import { defaultStyle, getShapeStyle } from 'lib/shape-styles'
20
+import styled from 'styles'
21
+import { boundsContainPolygon } from 'utils/bounds'
22
+
23
+const group = registerShapeUtils<GroupShape>({
24
+  boundsCache: new WeakMap([]),
25
+
26
+  create(props) {
27
+    return {
28
+      id: uuid(),
29
+      type: ShapeType.Group,
30
+      isGenerated: false,
31
+      name: 'Rectangle',
32
+      parentId: 'page0',
33
+      childIndex: 0,
34
+      point: [0, 0],
35
+      size: [1, 1],
36
+      radius: 2,
37
+      rotation: 0,
38
+      isAspectRatioLocked: false,
39
+      isLocked: false,
40
+      isHidden: false,
41
+      style: defaultStyle,
42
+      children: [],
43
+      ...props,
44
+    }
45
+  },
46
+
47
+  render(shape) {
48
+    const { id, size } = shape
49
+
50
+    return (
51
+      <g id={id}>
52
+        <StyledGroupShape id={id} width={size[0]} height={size[1]} />
53
+      </g>
54
+    )
55
+  },
56
+
57
+  translateTo(shape, point) {
58
+    shape.point = point
59
+    return this
60
+  },
61
+
62
+  getBounds(shape) {
63
+    if (!this.boundsCache.has(shape)) {
64
+      const [width, height] = shape.size
65
+      const bounds = {
66
+        minX: 0,
67
+        maxX: width,
68
+        minY: 0,
69
+        maxY: height,
70
+        width,
71
+        height,
72
+      }
73
+
74
+      this.boundsCache.set(shape, bounds)
75
+    }
76
+
77
+    return translateBounds(this.boundsCache.get(shape), shape.point)
78
+  },
79
+
80
+  hitTest() {
81
+    return false
82
+  },
83
+
84
+  hitTestBounds(shape, brushBounds) {
85
+    return false
86
+  },
87
+
88
+  transform(shape, bounds, { initialShape, transformOrigin, scaleX, scaleY }) {
89
+    if (shape.rotation === 0 && !shape.isAspectRatioLocked) {
90
+      shape.size = [bounds.width, bounds.height]
91
+      shape.point = [bounds.minX, bounds.minY]
92
+    } else {
93
+      shape.size = vec.mul(
94
+        initialShape.size,
95
+        Math.min(Math.abs(scaleX), Math.abs(scaleY))
96
+      )
97
+
98
+      shape.point = [
99
+        bounds.minX +
100
+          (bounds.width - shape.size[0]) *
101
+            (scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
102
+        bounds.minY +
103
+          (bounds.height - shape.size[1]) *
104
+            (scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
105
+      ]
106
+
107
+      shape.rotation =
108
+        (scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
109
+          ? -initialShape.rotation
110
+          : initialShape.rotation
111
+    }
112
+
113
+    return this
114
+  },
115
+
116
+  transformSingle(shape, bounds) {
117
+    shape.size = [bounds.width, bounds.height]
118
+    shape.point = [bounds.minX, bounds.minY]
119
+    return this
120
+  },
121
+
122
+  onChildrenChange(shape, children) {
123
+    const childBounds = getCommonBounds(
124
+      ...children.map((child) => getShapeUtils(child).getRotatedBounds(child))
125
+    )
126
+
127
+    // const c1 = this.getCenter(shape)
128
+    // const c2 = getBoundsCenter(childBounds)
129
+
130
+    // const [x0, y0] = vec.rotWith(shape.point, c1, shape.rotation)
131
+    // const [w0, h0] = vec.rotWith(shape.size, c1, shape.rotation)
132
+    // const [x1, y1] = vec.rotWith(
133
+    //   [childBounds.minX, childBounds.minY],
134
+    //   c2,
135
+    //   shape.rotation
136
+    // )
137
+    // const [w1, h1] = vec.rotWith(
138
+    //   [childBounds.width, childBounds.height],
139
+    //   c2,
140
+    //   shape.rotation
141
+    // )
142
+
143
+    // let delta: number[]
144
+
145
+    // if (h0 === h1 && w0 !== w1) {
146
+    //   if (x0 < x1) {
147
+    //     // moving left edge, pin right edge
148
+    //     delta = vec.sub([x1 + w1, y1 + h1 / 2], [x0 + w0, y0 + h0 / 2])
149
+    //   } else {
150
+    //     // moving right edge, pin left edge
151
+    //     delta = vec.sub([x1, y1 + h1 / 2], [x0, y0 + h0 / 2])
152
+    //   }
153
+    // } else if (h0 !== h1 && w0 === w1) {
154
+    //   if (y0 < y1) {
155
+    //     // moving top edge, pin bottom edge
156
+    //     delta = vec.sub([x1 + w1 / 2, y1 + h1], [x0 + w0 / 2, y0 + h0])
157
+    //   } else {
158
+    //     // moving bottom edge, pin top edge
159
+    //     delta = vec.sub([x1 + w1 / 2, y1], [x0 + w0 / 2, y0])
160
+    //   }
161
+    // } else if (x0 !== x1) {
162
+    //   if (y0 !== y1) {
163
+    //     // moving top left, pin bottom right
164
+    //     delta = vec.sub([x1 + w1, y1 + h1], [x0 + w0, y0 + h0])
165
+    //   } else {
166
+    //     // moving bottom left, pin top right
167
+    //     delta = vec.sub([x1 + w1, y1], [x0 + w0, y0])
168
+    //   }
169
+    // } else if (y0 !== y1) {
170
+    //   // moving top right, pin bottom left
171
+    //   delta = vec.sub([x1, y1 + h1], [x0, y0 + h0])
172
+    // } else {
173
+    //   // moving bottom right, pin top left
174
+    //   delta = vec.sub([x1, y1], [x0, y0])
175
+    // }
176
+
177
+    // if (shape.rotation !== 0) {
178
+    //   shape.point = vec.sub(shape.point, delta)
179
+    // }
180
+
181
+    shape.point = [childBounds.minX, childBounds.minY] //vec.add([x1, y1], delta)
182
+    shape.size = [childBounds.width, childBounds.height]
183
+
184
+    return this
185
+  },
186
+})
187
+
188
+const StyledGroupShape = styled('rect', {
189
+  zDash: 5,
190
+  zStrokeWidth: 1,
191
+})
192
+
193
+export default group

+ 56
- 19
lib/shape-utils/index.tsx View File

@@ -7,17 +7,9 @@ import {
7 7
   ShapeStyles,
8 8
   ShapeBinding,
9 9
   Mutable,
10
+  ShapeByType,
10 11
 } from 'types'
11
-import { v4 as uuid } from 'uuid'
12
-import circle from './circle'
13
-import dot from './dot'
14
-import polyline from './polyline'
15
-import rectangle from './rectangle'
16
-import ellipse from './ellipse'
17
-import line from './line'
18
-import ray from './ray'
19
-import draw from './draw'
20
-import arrow from './arrow'
12
+import * as vec from 'utils/vec'
21 13
 import {
22 14
   getBoundsCenter,
23 15
   getBoundsFromPoints,
@@ -28,6 +20,17 @@ import {
28 20
   boundsContainPolygon,
29 21
   pointInBounds,
30 22
 } from 'utils/bounds'
23
+import { v4 as uuid } from 'uuid'
24
+import circle from './circle'
25
+import dot from './dot'
26
+import polyline from './polyline'
27
+import rectangle from './rectangle'
28
+import ellipse from './ellipse'
29
+import line from './line'
30
+import ray from './ray'
31
+import draw from './draw'
32
+import arrow from './arrow'
33
+import group from './group'
31 34
 
32 35
 /*
33 36
 Shape Utiliies
@@ -62,6 +65,18 @@ export interface ShapeUtility<K extends Shape> {
62 65
     style: Partial<ShapeStyles>
63 66
   ): ShapeUtility<K>
64 67
 
68
+  translateBy(
69
+    this: ShapeUtility<K>,
70
+    shape: Mutable<K>,
71
+    point: number[]
72
+  ): ShapeUtility<K>
73
+
74
+  translateTo(
75
+    this: ShapeUtility<K>,
76
+    shape: Mutable<K>,
77
+    point: number[]
78
+  ): ShapeUtility<K>
79
+
65 80
   // Transform to fit a new bounding box when more than one shape is selected.
66 81
   transform(
67 82
     this: ShapeUtility<K>,
@@ -97,15 +112,22 @@ export interface ShapeUtility<K extends Shape> {
97 112
     value: K[P]
98 113
   ): ShapeUtility<K>
99 114
 
115
+  // Respond when any child of this shape changes.
116
+  onChildrenChange(
117
+    this: ShapeUtility<K>,
118
+    shape: Mutable<K>,
119
+    children: Shape[]
120
+  ): ShapeUtility<K>
121
+
100 122
   // Respond when a user moves one of the shape's bound elements.
101
-  onBindingMove?(
123
+  onBindingChange(
102 124
     this: ShapeUtility<K>,
103 125
     shape: Mutable<K>,
104 126
     bindings: Record<string, ShapeBinding>
105 127
   ): ShapeUtility<K>
106 128
 
107 129
   // Respond when a user moves one of the shape's handles.
108
-  onHandleMove?(
130
+  onHandleChange(
109 131
     this: ShapeUtility<K>,
110 132
     shape: Mutable<K>,
111 133
     handle: Partial<K['handles']>
@@ -142,6 +164,7 @@ const shapeUtilityMap: Record<ShapeType, ShapeUtility<Shape>> = {
142 164
   [ShapeType.Draw]: draw,
143 165
   [ShapeType.Arrow]: arrow,
144 166
   [ShapeType.Text]: arrow,
167
+  [ShapeType.Group]: group,
145 168
 }
146 169
 
147 170
 /**
@@ -180,6 +203,16 @@ function getDefaultShapeUtil<T extends Shape>(): ShapeUtility<T> {
180 203
       return <circle id={shape.id} />
181 204
     },
182 205
 
206
+    translateBy(shape, delta) {
207
+      shape.point = vec.add(shape.point, delta)
208
+      return this
209
+    },
210
+
211
+    translateTo(shape, point) {
212
+      shape.point = point
213
+      return this
214
+    },
215
+
183 216
     transform(shape, bounds) {
184 217
       shape.point = [bounds.minX, bounds.minY]
185 218
       return this
@@ -189,11 +222,15 @@ function getDefaultShapeUtil<T extends Shape>(): ShapeUtility<T> {
189 222
       return this.transform(shape, bounds, info)
190 223
     },
191 224
 
192
-    onBindingMove() {
225
+    onChildrenChange() {
226
+      return this
227
+    },
228
+
229
+    onBindingChange() {
193 230
       return this
194 231
     },
195 232
 
196
-    onHandleMove() {
233
+    onHandleChange() {
197 234
       return this
198 235
     },
199 236
 
@@ -258,11 +295,11 @@ export function registerShapeUtils<K extends Shape>(
258 295
   return Object.freeze({ ...getDefaultShapeUtil<K>(), ...shapeUtil })
259 296
 }
260 297
 
261
-export function createShape<T extends Shape>(
262
-  type: T['type'],
263
-  props: Partial<T>
264
-) {
265
-  return shapeUtilityMap[type].create(props) as T
298
+export function createShape<T extends ShapeType>(
299
+  type: T,
300
+  props: Partial<ShapeByType<T>>
301
+): ShapeByType<T> {
302
+  return shapeUtilityMap[type].create(props) as ShapeByType<T>
266 303
 }
267 304
 
268 305
 export default shapeUtilityMap

+ 7
- 7
state/commands/align.ts View File

@@ -27,7 +27,7 @@ export default function alignCommand(data: Data, type: AlignType) {
27 27
           case AlignType.Top: {
28 28
             for (let id in boundsForShapes) {
29 29
               const shape = shapes[id]
30
-              getShapeUtils(shape).setProperty(shape, 'point', [
30
+              getShapeUtils(shape).translateTo(shape, [
31 31
                 shape.point[0],
32 32
                 commonBounds.minY,
33 33
               ])
@@ -37,7 +37,7 @@ export default function alignCommand(data: Data, type: AlignType) {
37 37
           case AlignType.CenterVertical: {
38 38
             for (let id in boundsForShapes) {
39 39
               const shape = shapes[id]
40
-              getShapeUtils(shape).setProperty(shape, 'point', [
40
+              getShapeUtils(shape).translateTo(shape, [
41 41
                 shape.point[0],
42 42
                 midY - boundsForShapes[id].height / 2,
43 43
               ])
@@ -47,7 +47,7 @@ export default function alignCommand(data: Data, type: AlignType) {
47 47
           case AlignType.Bottom: {
48 48
             for (let id in boundsForShapes) {
49 49
               const shape = shapes[id]
50
-              getShapeUtils(shape).setProperty(shape, 'point', [
50
+              getShapeUtils(shape).translateTo(shape, [
51 51
                 shape.point[0],
52 52
                 commonBounds.maxY - boundsForShapes[id].height,
53 53
               ])
@@ -57,7 +57,7 @@ export default function alignCommand(data: Data, type: AlignType) {
57 57
           case AlignType.Left: {
58 58
             for (let id in boundsForShapes) {
59 59
               const shape = shapes[id]
60
-              getShapeUtils(shape).setProperty(shape, 'point', [
60
+              getShapeUtils(shape).translateTo(shape, [
61 61
                 commonBounds.minX,
62 62
                 shape.point[1],
63 63
               ])
@@ -67,7 +67,7 @@ export default function alignCommand(data: Data, type: AlignType) {
67 67
           case AlignType.CenterHorizontal: {
68 68
             for (let id in boundsForShapes) {
69 69
               const shape = shapes[id]
70
-              getShapeUtils(shape).setProperty(shape, 'point', [
70
+              getShapeUtils(shape).translateTo(shape, [
71 71
                 midX - boundsForShapes[id].width / 2,
72 72
                 shape.point[1],
73 73
               ])
@@ -77,7 +77,7 @@ export default function alignCommand(data: Data, type: AlignType) {
77 77
           case AlignType.Right: {
78 78
             for (let id in boundsForShapes) {
79 79
               const shape = shapes[id]
80
-              getShapeUtils(shape).setProperty(shape, 'point', [
80
+              getShapeUtils(shape).translateTo(shape, [
81 81
                 commonBounds.maxX - boundsForShapes[id].width,
82 82
                 shape.point[1],
83 83
               ])
@@ -91,7 +91,7 @@ export default function alignCommand(data: Data, type: AlignType) {
91 91
         for (let id in boundsForShapes) {
92 92
           const shape = shapes[id]
93 93
           const initialBounds = boundsForShapes[id]
94
-          getShapeUtils(shape).setProperty(shape, 'point', [
94
+          getShapeUtils(shape).translateTo(shape, [
95 95
             initialBounds.minX,
96 96
             initialBounds.minY,
97 97
           ])

+ 23
- 8
state/commands/delete-selected.ts View File

@@ -1,9 +1,10 @@
1
-import Command from "./command"
2
-import history from "../history"
3
-import { TranslateSnapshot } from "state/sessions/translate-session"
4
-import { Data } from "types"
5
-import { getPage } from "utils/utils"
6
-import { current } from "immer"
1
+import Command from './command'
2
+import history from '../history'
3
+import { TranslateSnapshot } from 'state/sessions/translate-session'
4
+import { Data } from 'types'
5
+import { getPage, updateParents } from 'utils/utils'
6
+import { current } from 'immer'
7
+import { getShapeUtils } from 'lib/shape-utils'
7 8
 
8 9
 export default function deleteSelected(data: Data) {
9 10
   const { currentPageId } = data
@@ -19,13 +20,27 @@ export default function deleteSelected(data: Data) {
19 20
   history.execute(
20 21
     data,
21 22
     new Command({
22
-      name: "delete_shapes",
23
-      category: "canvas",
23
+      name: 'delete_shapes',
24
+      category: 'canvas',
24 25
       manualSelection: true,
25 26
       do(data) {
26 27
         const page = getPage(data, currentPageId)
27 28
 
28 29
         for (let id of selectedIds) {
30
+          const shape = page.shapes[id]
31
+          if (shape.parentId !== data.currentPageId) {
32
+            const parent = page.shapes[shape.parentId]
33
+            getShapeUtils(parent)
34
+              .setProperty(
35
+                parent,
36
+                'children',
37
+                parent.children.filter((childId) => childId !== shape.id)
38
+              )
39
+              .onChildrenChange(
40
+                parent,
41
+                parent.children.map((id) => page.shapes[id])
42
+              )
43
+          }
29 44
           delete page.shapes[id]
30 45
         }
31 46
 

+ 5
- 11
state/commands/distribute.ts View File

@@ -59,7 +59,7 @@ export default function distributeCommand(data: Data, type: DistributeType) {
59 59
               for (let i = 0; i < entriesToMove.length; i++) {
60 60
                 const [id, bounds] = entriesToMove[i]
61 61
                 const shape = shapes[id]
62
-                getShapeUtils(shape).setProperty(shape, 'point', [
62
+                getShapeUtils(shape).translateTo(shape, [
63 63
                   x + step * i - bounds.width / 2,
64 64
                   bounds.minY,
65 65
                 ])
@@ -75,10 +75,7 @@ export default function distributeCommand(data: Data, type: DistributeType) {
75 75
               for (let i = 0; i < entriesToMove.length - 1; i++) {
76 76
                 const [id, bounds] = entriesToMove[i]
77 77
                 const shape = shapes[id]
78
-                getShapeUtils(shape).setProperty(shape, 'point', [
79
-                  x,
80
-                  bounds.minY,
81
-                ])
78
+                getShapeUtils(shape).translateTo(shape, [x, bounds.minY])
82 79
                 x += bounds.width + step
83 80
               }
84 81
             }
@@ -104,7 +101,7 @@ export default function distributeCommand(data: Data, type: DistributeType) {
104 101
               for (let i = 0; i < entriesToMove.length; i++) {
105 102
                 const [id, bounds] = entriesToMove[i]
106 103
                 const shape = shapes[id]
107
-                getShapeUtils(shape).setProperty(shape, 'point', [
104
+                getShapeUtils(shape).translateTo(shape, [
108 105
                   bounds.minX,
109 106
                   y + step * i - bounds.height / 2,
110 107
                 ])
@@ -120,10 +117,7 @@ export default function distributeCommand(data: Data, type: DistributeType) {
120 117
               for (let i = 0; i < entriesToMove.length - 1; i++) {
121 118
                 const [id, bounds] = entriesToMove[i]
122 119
                 const shape = shapes[id]
123
-                getShapeUtils(shape).setProperty(shape, 'point', [
124
-                  bounds.minX,
125
-                  y,
126
-                ])
120
+                getShapeUtils(shape).translateTo(shape, [bounds.minX, y])
127 121
                 y += bounds.height + step
128 122
               }
129 123
             }
@@ -137,7 +131,7 @@ export default function distributeCommand(data: Data, type: DistributeType) {
137 131
         for (let id in boundsForShapes) {
138 132
           const shape = shapes[id]
139 133
           const initialBounds = boundsForShapes[id]
140
-          getShapeUtils(shape).setProperty(shape, 'point', [
134
+          getShapeUtils(shape).translateTo(shape, [
141 135
             initialBounds.minX,
142 136
             initialBounds.minY,
143 137
           ])

+ 136
- 0
state/commands/group.ts View File

@@ -0,0 +1,136 @@
1
+import Command from './command'
2
+import history from '../history'
3
+import { Data, GroupShape, Shape, ShapeType } from 'types'
4
+import {
5
+  getCommonBounds,
6
+  getPage,
7
+  getSelectedShapes,
8
+  getShape,
9
+} from 'utils/utils'
10
+import { current } from 'immer'
11
+import { createShape, getShapeUtils } from 'lib/shape-utils'
12
+import { PropsOfType } from 'types'
13
+import { v4 as uuid } from 'uuid'
14
+import commands from '.'
15
+
16
+export default function groupCommand(data: Data) {
17
+  const cData = current(data)
18
+  const { currentPageId, selectedIds } = cData
19
+
20
+  const initialShapes = getSelectedShapes(cData).sort(
21
+    (a, b) => a.childIndex - b.childIndex
22
+  )
23
+
24
+  const isAllSameParent = initialShapes.every(
25
+    (shape, i) => i === 0 || shape.parentId === initialShapes[i - 1].parentId
26
+  )
27
+
28
+  let newGroupParentId: string
29
+  let newGroupShape: GroupShape
30
+  let oldGroupShape: GroupShape
31
+
32
+  const selectedShapeIds = initialShapes.map((s) => s.id)
33
+
34
+  const parentIds = Array.from(
35
+    new Set(initialShapes.map((s) => s.parentId)).values()
36
+  )
37
+
38
+  const commonBounds = getCommonBounds(
39
+    ...initialShapes.map((shape) =>
40
+      getShapeUtils(shape).getRotatedBounds(shape)
41
+    )
42
+  )
43
+
44
+  if (isAllSameParent) {
45
+    const parentId = initialShapes[0].parentId
46
+    if (parentId === currentPageId) {
47
+      newGroupParentId = currentPageId
48
+    } else {
49
+      // Are all of the parent's children selected?
50
+      const parent = getShape(data, parentId) as GroupShape
51
+
52
+      if (parent.children.length === initialShapes.length) {
53
+        // !
54
+        // !
55
+        // !
56
+        // Hey! We're not going any further. We need to ungroup those shapes.
57
+        commands.ungroup(data)
58
+        return
59
+      } else {
60
+        newGroupParentId = parentId
61
+      }
62
+    }
63
+  } else {
64
+    // Find the least-deep parent among the shapes and add the group as a child
65
+    let minDepth = Infinity
66
+
67
+    for (let parentId of initialShapes.map((shape) => shape.parentId)) {
68
+      const depth = getShapeDepth(data, parentId)
69
+      if (depth < minDepth) {
70
+        minDepth = depth
71
+        newGroupParentId = parentId
72
+      }
73
+    }
74
+  }
75
+
76
+  newGroupShape = createShape(ShapeType.Group, {
77
+    parentId: newGroupParentId,
78
+    point: [commonBounds.minX, commonBounds.minY],
79
+    size: [commonBounds.width, commonBounds.height],
80
+    children: selectedShapeIds,
81
+  })
82
+
83
+  history.execute(
84
+    data,
85
+    new Command({
86
+      name: 'group_shapes',
87
+      category: 'canvas',
88
+      do(data) {
89
+        const { shapes } = getPage(data, currentPageId)
90
+
91
+        // Remove shapes from old parents
92
+        for (const parentId of parentIds) {
93
+          if (parentId === currentPageId) continue
94
+
95
+          const shape = shapes[parentId] as GroupShape
96
+          getShapeUtils(shape).setProperty(
97
+            shape,
98
+            'children',
99
+            shape.children.filter((id) => !selectedIds.has(id))
100
+          )
101
+        }
102
+
103
+        shapes[newGroupShape.id] = newGroupShape
104
+        data.selectedIds.clear()
105
+        data.selectedIds.add(newGroupShape.id)
106
+        initialShapes.forEach(({ id }, i) => {
107
+          const shape = shapes[id]
108
+          getShapeUtils(shape)
109
+            .setProperty(shape, 'parentId', newGroupShape.id)
110
+            .setProperty(shape, 'childIndex', i)
111
+        })
112
+      },
113
+      undo(data) {
114
+        const { shapes } = getPage(data, currentPageId)
115
+        data.selectedIds.clear()
116
+
117
+        delete shapes[newGroupShape.id]
118
+        initialShapes.forEach(({ id, parentId, childIndex }, i) => {
119
+          data.selectedIds.add(id)
120
+          const shape = shapes[id]
121
+          getShapeUtils(shape)
122
+            .setProperty(shape, 'parentId', parentId)
123
+            .setProperty(shape, 'childIndex', childIndex)
124
+        })
125
+      },
126
+    })
127
+  )
128
+}
129
+
130
+function getShapeDepth(data: Data, id: string, depth = 0) {
131
+  if (id === data.currentPageId) {
132
+    return depth
133
+  }
134
+
135
+  return getShapeDepth(data, getShape(data, id).parentId, depth + 1)
136
+}

+ 2
- 2
state/commands/handle.ts View File

@@ -22,14 +22,14 @@ export default function handleCommand(
22 22
 
23 23
         const shape = getPage(data, currentPageId).shapes[initialShape.id]
24 24
 
25
-        getShapeUtils(shape).onHandleMove(shape, initialShape.handles)
25
+        getShapeUtils(shape).onHandleChange(shape, initialShape.handles)
26 26
       },
27 27
       undo(data) {
28 28
         const { initialShape, currentPageId } = before
29 29
 
30 30
         const shape = getPage(data, currentPageId).shapes[initialShape.id]
31 31
 
32
-        getShapeUtils(shape).onHandleMove(shape, initialShape.handles)
32
+        getShapeUtils(shape).onHandleChange(shape, initialShape.handles)
33 33
       },
34 34
     })
35 35
   )

+ 4
- 0
state/commands/index.ts View File

@@ -20,6 +20,8 @@ import transform from './transform'
20 20
 import transformSingle from './transform-single'
21 21
 import translate from './translate'
22 22
 import handle from './handle'
23
+import group from './group'
24
+import ungroup from './ungroup'
23 25
 
24 26
 const commands = {
25 27
   align,
@@ -44,6 +46,8 @@ const commands = {
44 46
   transformSingle,
45 47
   translate,
46 48
   handle,
49
+  group,
50
+  ungroup,
47 51
 }
48 52
 
49 53
 export default commands

+ 4
- 1
state/commands/transform-single.ts View File

@@ -4,7 +4,7 @@ import { Data, Corner, Edge } from 'types'
4 4
 import { getShapeUtils } from 'lib/shape-utils'
5 5
 import { current } from 'immer'
6 6
 import { TransformSingleSnapshot } from 'state/sessions/transform-single-session'
7
-import { getPage } from 'utils/utils'
7
+import { getPage, updateParents } from 'utils/utils'
8 8
 
9 9
 export default function transformSingleCommand(
10 10
   data: Data,
@@ -29,6 +29,8 @@ export default function transformSingleCommand(
29 29
         data.selectedIds.add(id)
30 30
 
31 31
         shapes[id] = shape
32
+
33
+        updateParents(data, [id])
32 34
       },
33 35
       undo(data) {
34 36
         const { id, type, initialShapeBounds } = before
@@ -50,6 +52,7 @@ export default function transformSingleCommand(
50 52
             scaleY: 1,
51 53
             transformOrigin: [0.5, 0.5],
52 54
           })
55
+          updateParents(data, [id])
53 56
         }
54 57
       },
55 58
     })

+ 5
- 1
state/commands/transform.ts View File

@@ -3,7 +3,7 @@ import history from '../history'
3 3
 import { Data } from 'types'
4 4
 import { TransformSnapshot } from 'state/sessions/transform-session'
5 5
 import { getShapeUtils } from 'lib/shape-utils'
6
-import { getPage } from 'utils/utils'
6
+import { getPage, updateParents } from 'utils/utils'
7 7
 
8 8
 export default function transformCommand(
9 9
   data: Data,
@@ -37,6 +37,8 @@ export default function transformCommand(
37 37
             scaleY,
38 38
           })
39 39
         }
40
+
41
+        updateParents(data, Object.keys(shapeBounds))
40 42
       },
41 43
       undo(data) {
42 44
         const { type, shapeBounds } = before
@@ -56,6 +58,8 @@ export default function transformCommand(
56 58
             scaleY: scaleX < 0 ? scaleX * -1 : scaleX,
57 59
           })
58 60
         }
61
+
62
+        updateParents(data, Object.keys(shapeBounds))
59 63
       },
60 64
     })
61 65
   )

+ 42
- 18
state/commands/translate.ts View File

@@ -2,7 +2,7 @@ import Command from './command'
2 2
 import history from '../history'
3 3
 import { TranslateSnapshot } from 'state/sessions/translate-session'
4 4
 import { Data } from 'types'
5
-import { getPage } from 'utils/utils'
5
+import { getPage, updateParents } from 'utils/utils'
6 6
 import { getShapeUtils } from 'lib/shape-utils'
7 7
 
8 8
 export default function translateCommand(
@@ -22,39 +22,63 @@ export default function translateCommand(
22 22
 
23 23
         const { initialShapes, currentPageId } = after
24 24
         const { shapes } = getPage(data, currentPageId)
25
-        const { clones } = before // !
26
-
27
-        data.selectedIds.clear()
28 25
 
26
+        // Restore clones to document
29 27
         if (isCloning) {
30
-          for (const clone of clones) {
28
+          for (const clone of before.clones) {
31 29
             shapes[clone.id] = clone
30
+            if (clone.parentId !== data.currentPageId) {
31
+              const parent = shapes[clone.parentId]
32
+              getShapeUtils(parent).setProperty(parent, 'children', [
33
+                ...parent.children,
34
+                clone.id,
35
+              ])
36
+            }
32 37
           }
33 38
         }
34 39
 
40
+        // Move shapes (these initialShapes will include clones if any)
35 41
         for (const { id, point } of initialShapes) {
36 42
           const shape = shapes[id]
37
-          getShapeUtils(shape).setProperty(shape, 'point', point)
38
-          data.selectedIds.add(id)
43
+          getShapeUtils(shape).translateTo(shape, point)
39 44
         }
45
+
46
+        // Set selected shapes
47
+        data.selectedIds = new Set(initialShapes.map((s) => s.id))
48
+
49
+        // Update parents
50
+        updateParents(
51
+          data,
52
+          initialShapes.map((s) => s.id)
53
+        )
40 54
       },
41 55
       undo(data) {
42
-        const { initialShapes, clones, currentPageId } = before
56
+        const { initialShapes, clones, currentPageId, initialParents } = before
43 57
         const { shapes } = getPage(data, currentPageId)
44 58
 
45
-        data.selectedIds.clear()
46
-
47
-        if (isCloning) {
48
-          for (const { id } of clones) {
49
-            delete shapes[id]
50
-          }
51
-        }
52
-
59
+        // Move shapes back to where they started
53 60
         for (const { id, point } of initialShapes) {
54 61
           const shape = shapes[id]
55
-          getShapeUtils(shape).setProperty(shape, 'point', point)
56
-          data.selectedIds.add(id)
62
+          getShapeUtils(shape).translateTo(shape, point)
57 63
         }
64
+
65
+        // Delete clones
66
+        if (isCloning) for (const { id } of clones) delete shapes[id]
67
+
68
+        // Set selected shapes
69
+        data.selectedIds = new Set(initialShapes.map((s) => s.id))
70
+
71
+        // Restore children on parents
72
+        initialParents.forEach(({ id, children }) => {
73
+          const parent = shapes[id]
74
+          getShapeUtils(parent).setProperty(parent, 'children', children)
75
+        })
76
+
77
+        // Update parents
78
+        updateParents(
79
+          data,
80
+          initialShapes.map((s) => s.id)
81
+        )
58 82
       },
59 83
     })
60 84
   )

+ 102
- 0
state/commands/ungroup.ts View File

@@ -0,0 +1,102 @@
1
+import Command from './command'
2
+import history from '../history'
3
+import { Data, GroupShape, Shape, ShapeType } from 'types'
4
+import {
5
+  getCommonBounds,
6
+  getPage,
7
+  getSelectedShapes,
8
+  getShape,
9
+} from 'utils/utils'
10
+import { current } from 'immer'
11
+import { createShape, getShapeUtils } from 'lib/shape-utils'
12
+import { PropsOfType } from 'types'
13
+import { v4 as uuid } from 'uuid'
14
+
15
+export default function ungroupCommand(data: Data) {
16
+  const cData = current(data)
17
+  const { currentPageId, selectedIds } = cData
18
+
19
+  const selectedGroups = getSelectedShapes(cData)
20
+    .filter((shape) => shape.type === ShapeType.Group)
21
+    .sort((a, b) => a.childIndex - b.childIndex)
22
+
23
+  // Are all of the shapes already in the same group?
24
+  // - ungroup the shapes
25
+  // Otherwise...
26
+  // - remove the shapes from any existing group and add them to a new one
27
+
28
+  history.execute(
29
+    data,
30
+    new Command({
31
+      name: 'ungroup_shapes',
32
+      category: 'canvas',
33
+      do(data) {
34
+        const { shapes } = getPage(data)
35
+
36
+        // Remove shapes from old parents
37
+        for (const oldGroupShape of selectedGroups) {
38
+          const siblings = (
39
+            oldGroupShape.parentId === currentPageId
40
+              ? Object.values(shapes).filter(
41
+                  (shape) => shape.parentId === currentPageId
42
+                )
43
+              : shapes[oldGroupShape.parentId].children.map((id) => shapes[id])
44
+          ).sort((a, b) => a.childIndex - b.childIndex)
45
+
46
+          const trueIndex = siblings.findIndex((s) => s.id === oldGroupShape.id)
47
+
48
+          let step: number
49
+
50
+          if (trueIndex === siblings.length - 1) {
51
+            step = 1
52
+          } else {
53
+            step =
54
+              (siblings[trueIndex + 1].childIndex - oldGroupShape.childIndex) /
55
+              (oldGroupShape.children.length + 1)
56
+          }
57
+
58
+          data.selectedIds.clear()
59
+
60
+          // Move shapes to page
61
+          oldGroupShape.children
62
+            .map((id) => shapes[id])
63
+            .forEach(({ id }, i) => {
64
+              const shape = shapes[id]
65
+              data.selectedIds.add(id)
66
+              getShapeUtils(shape)
67
+                .setProperty(shape, 'parentId', oldGroupShape.parentId)
68
+                .setProperty(
69
+                  shape,
70
+                  'childIndex',
71
+                  oldGroupShape.childIndex + step * i
72
+                )
73
+            })
74
+
75
+          delete shapes[oldGroupShape.id]
76
+        }
77
+      },
78
+      undo(data) {
79
+        const { shapes } = getPage(data, currentPageId)
80
+        selectedIds.clear()
81
+        selectedGroups.forEach((group) => {
82
+          selectedIds.add(group.id)
83
+          shapes[group.id] = group
84
+          group.children.forEach((id, i) => {
85
+            const shape = shapes[id]
86
+            getShapeUtils(shape)
87
+              .setProperty(shape, 'parentId', group.id)
88
+              .setProperty(shape, 'childIndex', i)
89
+          })
90
+        })
91
+      },
92
+    })
93
+  )
94
+}
95
+
96
+function getShapeDepth(data: Data, id: string, depth = 0) {
97
+  if (id === data.currentPageId) {
98
+    return depth
99
+  }
100
+
101
+  return getShapeDepth(data, getShape(data, id).parentId, depth + 1)
102
+}

+ 26
- 12
state/data.ts View File

@@ -114,18 +114,32 @@ export const defaultDocument: Data['document'] = {
114 114
         //     strokeWidth: 1,
115 115
         //   },
116 116
         // }),
117
-        // shape1: shapeUtils[ShapeType.Rectangle].create({
118
-        //   id: 'shape1',
119
-        //   name: 'Shape 1',
120
-        //   childIndex: 1,
121
-        //   point: [400, 600],
122
-        //   size: [200, 200],
123
-        //   style: {
124
-        //     stroke: shades.black,
125
-        //     fill: shades.lightGray,
126
-        //     strokeWidth: 1,
127
-        //   },
128
-        // }),
117
+        // Groups Testing
118
+        shapeA: shapeUtils[ShapeType.Rectangle].create({
119
+          id: 'shapeA',
120
+          name: 'Shape A',
121
+          childIndex: 1,
122
+          point: [0, 0],
123
+          size: [200, 200],
124
+          parentId: 'groupA',
125
+        }),
126
+        shapeB: shapeUtils[ShapeType.Rectangle].create({
127
+          id: 'shapeB',
128
+          name: 'Shape B',
129
+          childIndex: 2,
130
+          point: [220, 100],
131
+          size: [200, 200],
132
+          parentId: 'groupA',
133
+        }),
134
+        groupA: shapeUtils[ShapeType.Group].create({
135
+          id: 'groupA',
136
+          name: 'Group A',
137
+          childIndex: 2,
138
+          point: [0, 0],
139
+          size: [420, 300],
140
+          parentId: 'page1',
141
+          children: ['shapeA', 'shapeB'],
142
+        }),
129 143
       },
130 144
     },
131 145
     page2: {

+ 8
- 0
state/inputs.tsx View File

@@ -2,8 +2,11 @@ import React from 'react'
2 2
 import { PointerInfo } from 'types'
3 3
 import { isDarwin } from 'utils/utils'
4 4
 
5
+const DOUBLE_CLICK_DURATION = 300
6
+
5 7
 class Inputs {
6 8
   activePointerId?: number
9
+  lastPointerDownTime = 0
7 10
   points: Record<string, PointerInfo> = {}
8 11
 
9 12
   touchStart(e: TouchEvent | React.TouchEvent, target: string) {
@@ -128,6 +131,7 @@ class Inputs {
128 131
 
129 132
     delete this.points[e.pointerId]
130 133
     delete this.activePointerId
134
+    this.lastPointerDownTime = Date.now()
131 135
 
132 136
     return info
133 137
   }
@@ -143,6 +147,10 @@ class Inputs {
143 147
     )
144 148
   }
145 149
 
150
+  isDoubleClick() {
151
+    return Date.now() - this.lastPointerDownTime < DOUBLE_CLICK_DURATION
152
+  }
153
+
146 154
   get pointer() {
147 155
     return this.points[Object.keys(this.points)[0]]
148 156
   }

+ 8
- 4
state/sessions/arrow-session.ts View File

@@ -3,7 +3,7 @@ import * as vec from 'utils/vec'
3 3
 import BaseSession from './base-session'
4 4
 import commands from 'state/commands'
5 5
 import { current } from 'immer'
6
-import { getBoundsFromPoints, getPage } from 'utils/utils'
6
+import { getBoundsFromPoints, getPage, updateParents } from 'utils/utils'
7 7
 import { getShapeUtils } from 'lib/shape-utils'
8 8
 
9 9
 export default class PointsSession extends BaseSession {
@@ -51,12 +51,14 @@ export default class PointsSession extends BaseSession {
51 51
 
52 52
     const shape = getPage(data).shapes[id] as ArrowShape
53 53
 
54
-    getShapeUtils(shape).onHandleMove(shape, {
54
+    getShapeUtils(shape).onHandleChange(shape, {
55 55
       end: {
56 56
         ...shape.handles.end,
57 57
         point: vec.sub(point, shape.point),
58 58
       },
59 59
     })
60
+
61
+    updateParents(data, [shape])
60 62
   }
61 63
 
62 64
   cancel(data: Data) {
@@ -65,8 +67,10 @@ export default class PointsSession extends BaseSession {
65 67
     const shape = getPage(data).shapes[id] as ArrowShape
66 68
 
67 69
     getShapeUtils(shape)
68
-      .onHandleMove(shape, { end: initialShape.handles.end })
70
+      .onHandleChange(shape, { end: initialShape.handles.end })
69 71
       .setProperty(shape, 'point', initialShape.point)
72
+
73
+    updateParents(data, [shape])
70 74
   }
71 75
 
72 76
   complete(data: Data) {
@@ -96,7 +100,7 @@ export default class PointsSession extends BaseSession {
96 100
       ])
97 101
       .setProperty(shape, 'handles', nextHandles)
98 102
       .setProperty(shape, 'point', newPoint)
99
-      .onHandleMove(shape, nextHandles)
103
+      .onHandleChange(shape, nextHandles)
100 104
 
101 105
     commands.arrow(
102 106
       data,

+ 49
- 11
state/sessions/brush-session.ts View File

@@ -1,8 +1,8 @@
1 1
 import { current } from 'immer'
2
-import { Bounds, Data } from 'types'
2
+import { Bounds, Data, ShapeType } from 'types'
3 3
 import BaseSession from './base-session'
4 4
 import { getShapeUtils } from 'lib/shape-utils'
5
-import { getBoundsFromPoints, getShapes } from 'utils/utils'
5
+import { getBoundsFromPoints, getPage, getShapes } from 'utils/utils'
6 6
 import * as vec from 'utils/vec'
7 7
 
8 8
 export default class BrushSession extends BaseSession {
@@ -23,13 +23,24 @@ export default class BrushSession extends BaseSession {
23 23
     const brushBounds = getBoundsFromPoints([origin, point])
24 24
 
25 25
     for (let id in snapshot.shapeHitTests) {
26
-      const test = snapshot.shapeHitTests[id]
26
+      const { test, selectId } = snapshot.shapeHitTests[id]
27 27
       if (test(brushBounds)) {
28
-        if (!data.selectedIds.has(id)) {
29
-          data.selectedIds.add(id)
28
+        // When brushing a shape, select its top group parent.
29
+        if (!data.selectedIds.has(selectId)) {
30
+          data.selectedIds.add(selectId)
30 31
         }
31
-      } else if (data.selectedIds.has(id)) {
32
-        data.selectedIds.delete(id)
32
+
33
+        // Possibly... select all of the top group parent's children too?
34
+        // const selectedId = getTopParentId(data, id)
35
+        // const idsToSelect = collectChildIds(data, selectedId)
36
+
37
+        // for (let id in idsToSelect) {
38
+        //   if (!data.selectedIds.has(id)) {
39
+        //     data.selectedIds.add(id)
40
+        //   }
41
+        // }
42
+      } else if (data.selectedIds.has(selectId)) {
43
+        data.selectedIds.delete(selectId)
33 44
       }
34 45
     }
35 46
 
@@ -55,12 +66,39 @@ export function getBrushSnapshot(data: Data) {
55 66
   return {
56 67
     selectedIds: new Set(data.selectedIds),
57 68
     shapeHitTests: Object.fromEntries(
58
-      getShapes(current(data)).map((shape) => [
59
-        shape.id,
60
-        (bounds: Bounds) => getShapeUtils(shape).hitTestBounds(shape, bounds),
61
-      ])
69
+      getShapes(current(data))
70
+        .filter((shape) => shape.type !== ShapeType.Group)
71
+        .map((shape) => [
72
+          shape.id,
73
+          {
74
+            selectId: getTopParentId(data, shape.id),
75
+            test: (bounds: Bounds) =>
76
+              getShapeUtils(shape).hitTestBounds(shape, bounds),
77
+          },
78
+        ])
62 79
     ),
63 80
   }
64 81
 }
65 82
 
66 83
 export type BrushSnapshot = ReturnType<typeof getBrushSnapshot>
84
+
85
+function getTopParentId(data: Data, id: string): string {
86
+  const shape = getPage(data).shapes[id]
87
+  return shape.parentId === data.currentPageId ||
88
+    shape.parentId === data.currentParentId
89
+    ? id
90
+    : getTopParentId(data, shape.parentId)
91
+}
92
+
93
+function collectChildIds(data: Data, id: string): string[] {
94
+  const shape = getPage(data).shapes[id]
95
+
96
+  if (shape.type === ShapeType.Group) {
97
+    return [
98
+      id,
99
+      ...shape.children.flatMap((childId) => collectChildIds(data, childId)),
100
+    ]
101
+  }
102
+
103
+  return [id]
104
+}

+ 6
- 7
state/sessions/draw-session.ts View File

@@ -2,7 +2,7 @@ import { current } from 'immer'
2 2
 import { Data, DrawShape } from 'types'
3 3
 import BaseSession from './base-session'
4 4
 import { getShapeUtils } from 'lib/shape-utils'
5
-import { getPage } from 'utils/utils'
5
+import { getPage, getShape, updateParents } from 'utils/utils'
6 6
 import * as vec from 'utils/vec'
7 7
 import commands from 'state/commands'
8 8
 
@@ -25,7 +25,7 @@ export default class BrushSession extends BaseSession {
25 25
 
26 26
     const page = getPage(data)
27 27
     const shape = page.shapes[id]
28
-    getShapeUtils(shape).setProperty(shape, 'point', point)
28
+    getShapeUtils(shape).translateTo(shape, point)
29 29
   }
30 30
 
31 31
   update = (data: Data, point: number[], isLocked = false) => {
@@ -73,17 +73,16 @@ export default class BrushSession extends BaseSession {
73 73
     this.points.push(next)
74 74
     this.previous = point
75 75
 
76
-    const page = getPage(data)
77
-    const shape = page.shapes[snapshot.id] as DrawShape
78
-
76
+    const shape = getShape(data, snapshot.id) as DrawShape
79 77
     getShapeUtils(shape).setProperty(shape, 'points', [...this.points])
78
+    updateParents(data, [shape])
80 79
   }
81 80
 
82 81
   cancel = (data: Data) => {
83 82
     const { snapshot } = this
84
-    const page = getPage(data)
85
-    const shape = page.shapes[snapshot.id] as DrawShape
83
+    const shape = getShape(data, snapshot.id) as DrawShape
86 84
     getShapeUtils(shape).setProperty(shape, 'points', snapshot.points)
85
+    updateParents(data, [shape])
87 86
   }
88 87
 
89 88
   complete = (data: Data) => {

+ 1
- 1
state/sessions/handle-session.ts View File

@@ -33,7 +33,7 @@ export default class HandleSession extends BaseSession {
33 33
 
34 34
     const handles = initialShape.handles
35 35
 
36
-    getShapeUtils(shape).onHandleMove(shape, {
36
+    getShapeUtils(shape).onHandleChange(shape, {
37 37
       [handleId]: {
38 38
         ...handles[handleId],
39 39
         point: vec.add(handles[handleId].point, delta),

+ 14
- 2
state/sessions/rotate-session.ts View File

@@ -11,6 +11,7 @@ import {
11 11
   getSelectedShapes,
12 12
   getRotatedBounds,
13 13
   getShapeBounds,
14
+  updateParents,
14 15
 } from 'utils/utils'
15 16
 import { getShapeUtils } from 'lib/shape-utils'
16 17
 
@@ -63,17 +64,28 @@ export default class RotateSession extends BaseSession {
63 64
         .setProperty(shape, 'rotation', (PI2 + nextRotation) % PI2)
64 65
         .setProperty(shape, 'point', nextPoint)
65 66
     }
67
+
68
+    updateParents(
69
+      data,
70
+      initialShapes.map((s) => s.id)
71
+    )
66 72
   }
67 73
 
68 74
   cancel(data: Data) {
69
-    const page = getPage(data, this.snapshot.currentPageId)
75
+    const { currentPageId, initialShapes } = this.snapshot
76
+    const page = getPage(data, currentPageId)
70 77
 
71
-    for (let { id, point, rotation } of this.snapshot.initialShapes) {
78
+    for (let { id, point, rotation } of initialShapes) {
72 79
       const shape = page.shapes[id]
73 80
       getShapeUtils(shape)
74 81
         .setProperty(shape, 'rotation', rotation)
75 82
         .setProperty(shape, 'point', point)
76 83
     }
84
+
85
+    updateParents(
86
+      data,
87
+      initialShapes.map((s) => s.id)
88
+    )
77 89
   }
78 90
 
79 91
   complete(data: Data) {

+ 7
- 2
state/sessions/transform-session.ts View File

@@ -13,6 +13,7 @@ import {
13 13
   getSelectedShapes,
14 14
   getShapes,
15 15
   getTransformedBoundingBox,
16
+  updateParents,
16 17
 } from 'utils/utils'
17 18
 
18 19
 export default class TransformSession extends BaseSession {
@@ -71,15 +72,17 @@ export default class TransformSession extends BaseSession {
71 72
         transformOrigin,
72 73
       })
73 74
     }
75
+
76
+    updateParents(data, Object.keys(shapeBounds))
74 77
   }
75 78
 
76 79
   cancel(data: Data) {
77 80
     const { currentPageId, shapeBounds } = this.snapshot
78 81
 
79
-    const page = getPage(data, currentPageId)
82
+    const { shapes } = getPage(data, currentPageId)
80 83
 
81 84
     for (let id in shapeBounds) {
82
-      const shape = page.shapes[id]
85
+      const shape = shapes[id]
83 86
 
84 87
       const { initialShape, initialShapeBounds, transformOrigin } =
85 88
         shapeBounds[id]
@@ -91,6 +94,8 @@ export default class TransformSession extends BaseSession {
91 94
         scaleY: 1,
92 95
         transformOrigin,
93 96
       })
97
+
98
+      updateParents(data, Object.keys(shapeBounds))
94 99
     }
95 100
   }
96 101
 

+ 5
- 0
state/sessions/transform-single-session.ts View File

@@ -12,6 +12,7 @@ import {
12 12
   getPage,
13 13
   getShape,
14 14
   getSelectedShapes,
15
+  updateParents,
15 16
 } from 'utils/utils'
16 17
 
17 18
 export default class TransformSingleSession extends BaseSession {
@@ -61,6 +62,8 @@ export default class TransformSingleSession extends BaseSession {
61 62
       scaleY: this.scaleY,
62 63
       transformOrigin: [0.5, 0.5],
63 64
     })
65
+
66
+    updateParents(data, [id])
64 67
   }
65 68
 
66 69
   cancel(data: Data) {
@@ -76,6 +79,8 @@ export default class TransformSingleSession extends BaseSession {
76 79
       scaleY: this.scaleY,
77 80
       transformOrigin: [0.5, 0.5],
78 81
     })
82
+
83
+    updateParents(data, [id])
79 84
   }
80 85
 
81 86
   complete(data: Data) {

+ 88
- 15
state/sessions/translate-session.ts View File

@@ -1,10 +1,15 @@
1
-import { Data } from 'types'
1
+import { Data, GroupShape, ShapeType } from 'types'
2 2
 import * as vec from 'utils/vec'
3 3
 import BaseSession from './base-session'
4 4
 import commands from 'state/commands'
5 5
 import { current } from 'immer'
6 6
 import { v4 as uuid } from 'uuid'
7
-import { getChildIndexAbove, getPage, getSelectedShapes } from 'utils/utils'
7
+import {
8
+  getChildIndexAbove,
9
+  getPage,
10
+  getSelectedShapes,
11
+  updateParents,
12
+} from 'utils/utils'
8 13
 import { getShapeUtils } from 'lib/shape-utils'
9 14
 
10 15
 export default class TranslateSession extends BaseSession {
@@ -20,7 +25,8 @@ export default class TranslateSession extends BaseSession {
20 25
   }
21 26
 
22 27
   update(data: Data, point: number[], isAligned: boolean, isCloning: boolean) {
23
-    const { currentPageId, clones, initialShapes } = this.snapshot
28
+    const { currentPageId, clones, initialShapes, initialParents } =
29
+      this.snapshot
24 30
     const { shapes } = getPage(data, currentPageId)
25 31
 
26 32
     const delta = vec.vec(this.origin, point)
@@ -40,19 +46,33 @@ export default class TranslateSession extends BaseSession {
40 46
 
41 47
         for (const { id, point } of initialShapes) {
42 48
           const shape = shapes[id]
43
-          getShapeUtils(shape).setProperty(shape, 'point', point)
49
+          getShapeUtils(shape).translateTo(shape, point)
44 50
         }
45 51
 
52
+        data.selectedIds.clear()
53
+
46 54
         for (const clone of clones) {
47
-          shapes[clone.id] = { ...clone }
48 55
           data.selectedIds.add(clone.id)
56
+          shapes[clone.id] = { ...clone }
57
+          if (clone.parentId !== data.currentPageId) {
58
+            const parent = shapes[clone.parentId]
59
+            getShapeUtils(parent).setProperty(parent, 'children', [
60
+              ...parent.children,
61
+              clone.id,
62
+            ])
63
+          }
49 64
         }
50 65
       }
51 66
 
52 67
       for (const { id, point } of clones) {
53 68
         const shape = shapes[id]
54
-        getShapeUtils(shape).setProperty(shape, 'point', vec.add(point, delta))
69
+        getShapeUtils(shape).translateTo(shape, vec.add(point, delta))
55 70
       }
71
+
72
+      updateParents(
73
+        data,
74
+        clones.map((c) => c.id)
75
+      )
56 76
     } else {
57 77
       if (this.isCloning) {
58 78
         this.isCloning = false
@@ -65,27 +85,65 @@ export default class TranslateSession extends BaseSession {
65 85
         for (const clone of clones) {
66 86
           delete shapes[clone.id]
67 87
         }
88
+
89
+        initialParents.forEach(
90
+          (parent) =>
91
+            ((shapes[parent.id] as GroupShape).children = parent.children)
92
+        )
68 93
       }
69 94
 
70
-      for (const { id, point } of initialShapes) {
71
-        const shape = shapes[id]
72
-        getShapeUtils(shape).setProperty(shape, 'point', vec.add(point, delta))
95
+      for (const initialShape of initialShapes) {
96
+        const shape = shapes[initialShape.id]
97
+        const next = vec.add(initialShape.point, delta)
98
+        const deltaForShape = vec.sub(next, shape.point)
99
+        getShapeUtils(shape).translateTo(shape, next)
100
+
101
+        if (shape.type === ShapeType.Group) {
102
+          for (let childId of shape.children) {
103
+            const childShape = shapes[childId]
104
+            getShapeUtils(childShape).translateBy(childShape, deltaForShape)
105
+          }
106
+        }
73 107
       }
108
+
109
+      updateParents(
110
+        data,
111
+        initialShapes.map((s) => s.id)
112
+      )
74 113
     }
75 114
   }
76 115
 
77 116
   cancel(data: Data) {
78
-    const { initialShapes, clones, currentPageId } = this.snapshot
117
+    const { initialShapes, initialParents, clones, currentPageId } =
118
+      this.snapshot
79 119
     const { shapes } = getPage(data, currentPageId)
80 120
 
81 121
     for (const { id, point } of initialShapes) {
82 122
       const shape = shapes[id]
83
-      getShapeUtils(shape).setProperty(shape, 'point', point)
123
+      const deltaForShape = vec.sub(point, shape.point)
124
+      getShapeUtils(shape).translateTo(shape, point)
125
+
126
+      if (shape.type === ShapeType.Group) {
127
+        for (let childId of shape.children) {
128
+          const childShape = shapes[childId]
129
+          getShapeUtils(childShape).translateBy(childShape, deltaForShape)
130
+        }
131
+      }
84 132
     }
85 133
 
86 134
     for (const { id } of clones) {
87 135
       delete shapes[id]
88 136
     }
137
+
138
+    initialParents.forEach(({ id, children }) => {
139
+      const shape = shapes[id]
140
+      getShapeUtils(shape).setProperty(shape, 'children', children)
141
+    })
142
+
143
+    updateParents(
144
+      data,
145
+      initialShapes.map((s) => s.id)
146
+    )
89 147
   }
90 148
 
91 149
   complete(data: Data) {
@@ -102,16 +160,31 @@ export default class TranslateSession extends BaseSession {
102 160
 
103 161
 export function getTranslateSnapshot(data: Data) {
104 162
   const cData = current(data)
105
-  const shapes = getSelectedShapes(cData).filter((shape) => !shape.isLocked)
106
-  const hasUnlockedShapes = shapes.length > 0
163
+  const page = getPage(cData)
164
+  const selectedShapes = getSelectedShapes(cData).filter(
165
+    (shape) => !shape.isLocked
166
+  )
167
+  const hasUnlockedShapes = selectedShapes.length > 0
168
+
169
+  const parents = Array.from(
170
+    new Set(selectedShapes.map((s) => s.parentId)).values()
171
+  )
172
+    .filter((id) => id !== data.currentPageId)
173
+    .map((id) => page.shapes[id])
107 174
 
108 175
   return {
109 176
     hasUnlockedShapes,
110 177
     currentPageId: data.currentPageId,
111
-    initialShapes: shapes.map(({ id, point }) => ({ id, point })),
112
-    clones: shapes.map((shape) => ({
178
+    initialParents: parents.map(({ id, children }) => ({ id, children })),
179
+    initialShapes: selectedShapes.map(({ id, point, parentId }) => ({
180
+      id,
181
+      point,
182
+      parentId,
183
+    })),
184
+    clones: selectedShapes.map((shape) => ({
113 185
       ...shape,
114 186
       id: uuid(),
187
+      parentId: shape.parentId,
115 188
       childIndex: getChildIndexAbove(cData, shape.id),
116 189
     })),
117 190
   }

+ 128
- 10
state/state.ts View File

@@ -15,9 +15,15 @@ import {
15 15
   getCurrentCamera,
16 16
   getPage,
17 17
   getSelectedBounds,
18
+  getSelectedShapes,
18 19
   getShape,
19 20
   screenToWorld,
20 21
   setZoomCSS,
22
+  translateBounds,
23
+  getParentOffset,
24
+  getParentRotation,
25
+  rotateBounds,
26
+  getBoundsCenter,
21 27
 } from 'utils/utils'
22 28
 import {
23 29
   Data,
@@ -62,6 +68,7 @@ const initialData: Data = {
62 68
   hoveredId: null,
63 69
   selectedIds: new Set([]),
64 70
   currentPageId: 'page1',
71
+  currentParentId: 'page1',
65 72
   currentCodeFileId: 'file0',
66 73
   codeControls: {},
67 74
   document: defaultDocument,
@@ -143,7 +150,7 @@ const state = createState({
143 150
         SELECTED_RECTANGLE_TOOL: { unless: 'isReadOnly', to: 'rectangle' },
144 151
         TOGGLED_CODE_PANEL_OPEN: 'toggleCodePanel',
145 152
         TOGGLED_STYLE_PANEL_OPEN: 'toggleStylePanel',
146
-        POINTED_CANVAS: 'closeStylePanel',
153
+        POINTED_CANVAS: ['closeStylePanel', 'clearCurrentParentId'],
147 154
         CHANGED_STYLE: ['updateStyles', 'applyStylesToSelection'],
148 155
         SELECTED_ALL: { to: 'selecting', do: 'selectAll' },
149 156
         NUDGED: { do: 'nudgeSelection' },
@@ -170,11 +177,19 @@ const state = createState({
170 177
             GENERATED_FROM_CODE: ['setCodeControls', 'setGeneratedShapes'],
171 178
             TOGGLED_TOOL_LOCK: 'toggleToolLock',
172 179
             MOVED: { if: 'hasSelection', do: 'moveSelection' },
173
-            ALIGNED: { if: 'hasSelection', do: 'alignSelection' },
174
-            STRETCHED: { if: 'hasSelection', do: 'stretchSelection' },
175
-            DISTRIBUTED: { if: 'hasSelection', do: 'distributeSelection' },
176 180
             DUPLICATED: { if: 'hasSelection', do: 'duplicateSelection' },
177 181
             ROTATED_CCW: { if: 'hasSelection', do: 'rotateSelectionCcw' },
182
+            ALIGNED: { if: 'hasMultipleSelection', do: 'alignSelection' },
183
+            STRETCHED: { if: 'hasMultipleSelection', do: 'stretchSelection' },
184
+            DISTRIBUTED: {
185
+              if: 'hasMultipleSelection',
186
+              do: 'distributeSelection',
187
+            },
188
+            GROUPED: { if: 'hasMultipleSelection', do: 'groupSelection' },
189
+            UNGROUPED: {
190
+              if: ['hasSelection', 'selectionIncludesGroups'],
191
+              do: 'ungroupSelection',
192
+            },
178 193
           },
179 194
           initial: 'notPointing',
180 195
           states: {
@@ -199,6 +214,14 @@ const state = createState({
199 214
                   else: { if: 'shapeIsHovered', do: 'clearHoveredId' },
200 215
                 },
201 216
                 UNHOVERED_SHAPE: 'clearHoveredId',
217
+                DOUBLE_POINTED_SHAPE: [
218
+                  'setDrilledPointedId',
219
+                  'clearSelectedIds',
220
+                  'pushPointedIdToSelectedIds',
221
+                  {
222
+                    to: 'pointingBounds',
223
+                  },
224
+                ],
202 225
                 POINTED_SHAPE: [
203 226
                   {
204 227
                     if: 'isPressingMetaKey',
@@ -738,6 +761,9 @@ const state = createState({
738 761
     hasSelection(data) {
739 762
       return data.selectedIds.size > 0
740 763
     },
764
+    hasMultipleSelection(data) {
765
+      return data.selectedIds.size > 1
766
+    },
741 767
     isToolLocked(data) {
742 768
       return data.settings.isToolLocked
743 769
     },
@@ -747,6 +773,11 @@ const state = createState({
747 773
     hasOnlyOnePage(data) {
748 774
       return Object.keys(data.document.pages).length === 1
749 775
     },
776
+    selectionIncludesGroups(data) {
777
+      return getSelectedShapes(data).some(
778
+        (shape) => shape.type === ShapeType.Group
779
+      )
780
+    },
750 781
   },
751 782
   actions: {
752 783
     /* ---------------------- Pages --------------------- */
@@ -763,6 +794,7 @@ const state = createState({
763 794
     /* --------------------- Shapes --------------------- */
764 795
     createShape(data, payload, type: ShapeType) {
765 796
       const shape = createShape(type, {
797
+        parentId: data.currentPageId,
766 798
         point: screenToWorld(payload.point, data),
767 799
         style: getCurrent(data.currentStyle),
768 800
       })
@@ -1005,7 +1037,16 @@ const state = createState({
1005 1037
       data.hoveredId = undefined
1006 1038
     },
1007 1039
     setPointedId(data, payload: PointerInfo) {
1008
-      data.pointedId = payload.target
1040
+      data.pointedId = getPointedId(data, payload.target)
1041
+      data.currentParentId = getParentId(data, data.pointedId)
1042
+    },
1043
+    setDrilledPointedId(data, payload: PointerInfo) {
1044
+      data.pointedId = getDrilledPointedId(data, payload.target)
1045
+      data.currentParentId = getParentId(data, data.pointedId)
1046
+    },
1047
+    clearCurrentParentId(data) {
1048
+      data.currentParentId = data.currentPageId
1049
+      data.pointedId = undefined
1009 1050
     },
1010 1051
     clearPointedId(data) {
1011 1052
       data.pointedId = undefined
@@ -1050,6 +1091,12 @@ const state = createState({
1050 1091
     rotateSelectionCcw(data) {
1051 1092
       commands.rotateCcw(data)
1052 1093
     },
1094
+    groupSelection(data) {
1095
+      commands.group(data)
1096
+    },
1097
+    ungroupSelection(data) {
1098
+      commands.ungroup(data)
1099
+    },
1053 1100
 
1054 1101
     /* ---------------------- Tool ---------------------- */
1055 1102
 
@@ -1336,7 +1383,7 @@ const state = createState({
1336 1383
     },
1337 1384
 
1338 1385
     restoreSavedData(data) {
1339
-      history.load(data)
1386
+      // history.load(data)
1340 1387
     },
1341 1388
 
1342 1389
     clearBoundsRotation(data) {
@@ -1365,14 +1412,46 @@ const state = createState({
1365 1412
           return null
1366 1413
         }
1367 1414
 
1368
-        const shapeUtils = getShapeUtils(shapes[0])
1415
+        const shape = shapes[0]
1416
+        const shapeUtils = getShapeUtils(shape)
1417
+
1369 1418
         if (!shapeUtils.canTransform) return null
1370
-        return shapeUtils.getBounds(shapes[0])
1419
+
1420
+        let bounds = shapeUtils.getBounds(shape)
1421
+
1422
+        let parentId = shape.parentId
1423
+
1424
+        while (parentId !== data.currentPageId) {
1425
+          const parent = page.shapes[parentId]
1426
+
1427
+          bounds = rotateBounds(
1428
+            bounds,
1429
+            getBoundsCenter(getShapeUtils(parent).getBounds(parent)),
1430
+            parent.rotation
1431
+          )
1432
+
1433
+          bounds.rotation = parent.rotation
1434
+
1435
+          parentId = parent.parentId
1436
+        }
1437
+
1438
+        return bounds
1371 1439
       }
1372 1440
 
1373
-      return getCommonBounds(
1374
-        ...shapes.map((shape) => getShapeUtils(shape).getRotatedBounds(shape))
1441
+      const commonBounds = getCommonBounds(
1442
+        ...shapes.map((shape) => {
1443
+          const parentOffset = getParentOffset(data, shape.id)
1444
+          const parentRotation = getParentRotation(data, shape.id)
1445
+          const bounds = getShapeUtils(shape).getRotatedBounds(shape)
1446
+
1447
+          return translateBounds(
1448
+            rotateBounds(bounds, getBoundsCenter(bounds), parentRotation),
1449
+            vec.neg(parentOffset)
1450
+          )
1451
+        })
1375 1452
       )
1453
+
1454
+      return commonBounds
1376 1455
     },
1377 1456
     selectedStyle(data) {
1378 1457
       const selectedIds = Array.from(data.selectedIds.values())
@@ -1415,3 +1494,42 @@ export const useSelector = createSelectorHook(state)
1415 1494
 function getCameraZoom(zoom: number) {
1416 1495
   return clamp(zoom, 0.1, 5)
1417 1496
 }
1497
+
1498
+function getParentId(data: Data, id: string) {
1499
+  const shape = getPage(data).shapes[id]
1500
+  return shape.parentId
1501
+}
1502
+
1503
+function getPointedId(data: Data, id: string) {
1504
+  const shape = getPage(data).shapes[id]
1505
+
1506
+  return shape.parentId === data.currentParentId ||
1507
+    shape.parentId === data.currentPageId
1508
+    ? id
1509
+    : getPointedId(data, shape.parentId)
1510
+}
1511
+
1512
+function getDrilledPointedId(data: Data, id: string) {
1513
+  const shape = getPage(data).shapes[id]
1514
+  return shape.parentId === data.currentPageId ||
1515
+    shape.parentId === data.pointedId ||
1516
+    shape.parentId === data.currentParentId
1517
+    ? id
1518
+    : getDrilledPointedId(data, shape.parentId)
1519
+}
1520
+
1521
+function hasPointedIdInChildren(data: Data, id: string, pointedId: string) {
1522
+  const shape = getPage(data).shapes[id]
1523
+
1524
+  if (shape.type !== ShapeType.Group) {
1525
+    return false
1526
+  }
1527
+
1528
+  if (shape.children.includes(pointedId)) {
1529
+    return true
1530
+  }
1531
+
1532
+  return shape.children.some((childId) =>
1533
+    hasPointedIdInChildren(data, childId, pointedId)
1534
+  )
1535
+}

+ 15
- 15
types.ts View File

@@ -26,6 +26,7 @@ export interface Data {
26 26
   pointedId?: string
27 27
   hoveredId?: string
28 28
   currentPageId: string
29
+  currentParentId: string
29 30
   currentCodeFileId: string
30 31
   codeControls: Record<string, CodeControl>
31 32
   document: {
@@ -66,14 +67,9 @@ export enum ShapeType {
66 67
   Draw = 'draw',
67 68
   Arrow = 'arrow',
68 69
   Text = 'text',
70
+  Group = 'group',
69 71
 }
70 72
 
71
-// Consider:
72
-// Glob = "glob",
73
-// Spline = "spline",
74
-// Cubic = "cubic",
75
-// Conic = "conic",
76
-
77 73
 export enum ColorStyle {
78 74
   White = 'White',
79 75
   LightGray = 'LightGray',
@@ -108,12 +104,6 @@ export type ShapeStyles = {
108 104
   isFilled: boolean
109 105
 }
110 106
 
111
-// export type ShapeStyles = Partial<
112
-//   React.SVGProps<SVGUseElement> & {
113
-//     dash: DashStyle
114
-//   }
115
-// >
116
-
117 107
 export interface BaseShape {
118 108
   id: string
119 109
   type: ShapeType
@@ -122,10 +112,11 @@ export interface BaseShape {
122 112
   isGenerated: boolean
123 113
   name: string
124 114
   point: number[]
115
+  style: ShapeStyles
125 116
   rotation: number
117
+  children?: string[]
126 118
   bindings?: Record<string, ShapeBinding>
127 119
   handles?: Record<string, ShapeHandle>
128
-  style: ShapeStyles
129 120
   isLocked: boolean
130 121
   isHidden: boolean
131 122
   isAspectRatioLocked: boolean
@@ -189,6 +180,12 @@ export interface TextShape extends BaseShape {
189 180
   text: string
190 181
 }
191 182
 
183
+export interface GroupShape extends BaseShape {
184
+  type: ShapeType.Group
185
+  children: string[]
186
+  size: number[]
187
+}
188
+
192 189
 export type MutableShape =
193 190
   | DotShape
194 191
   | CircleShape
@@ -200,8 +197,7 @@ export type MutableShape =
200 197
   | RectangleShape
201 198
   | ArrowShape
202 199
   | TextShape
203
-
204
-export type Shape = Readonly<MutableShape>
200
+  | GroupShape
205 201
 
206 202
 export interface Shapes {
207 203
   [ShapeType.Dot]: Readonly<DotShape>
@@ -214,8 +210,11 @@ export interface Shapes {
214 210
   [ShapeType.Rectangle]: Readonly<RectangleShape>
215 211
   [ShapeType.Arrow]: Readonly<ArrowShape>
216 212
   [ShapeType.Text]: Readonly<TextShape>
213
+  [ShapeType.Group]: Readonly<GroupShape>
217 214
 }
218 215
 
216
+export type Shape = Readonly<MutableShape>
217
+
219 218
 export type ShapeByType<T extends ShapeType> = Shapes[T]
220 219
 
221 220
 export interface CodeFile {
@@ -276,6 +275,7 @@ export interface Bounds {
276 275
   maxY: number
277 276
   width: number
278 277
   height: number
278
+  rotation?: number
279 279
 }
280 280
 
281 281
 export interface RotatedBounds extends Bounds {

+ 62
- 1
utils/utils.ts View File

@@ -1,6 +1,15 @@
1 1
 import Vector from 'lib/code/vector'
2 2
 import React from 'react'
3
-import { Data, Bounds, Edge, Corner, Shape, ShapeStyles } from 'types'
3
+import {
4
+  Data,
5
+  Bounds,
6
+  Edge,
7
+  Corner,
8
+  Shape,
9
+  ShapeStyles,
10
+  GroupShape,
11
+  ShapeType,
12
+} from 'types'
4 13
 import * as vec from './vec'
5 14
 import _isMobile from 'ismobilejs'
6 15
 import { getShapeUtils } from 'lib/shape-utils'
@@ -1586,3 +1595,55 @@ export function isAngleBetween(a: number, b: number, c: number) {
1586 1595
 export function getCurrentCamera(data: Data) {
1587 1596
   return data.pageStates[data.currentPageId].camera
1588 1597
 }
1598
+
1599
+// export function updateChildren(data: Data, changedShapes: Shape[]) {
1600
+//   if (changedShapes.length === 0) return
1601
+//   const { shapes } = getPage(data)
1602
+
1603
+//   changedShapes.forEach((shape) => {
1604
+//     if (shape.type === ShapeType.Group) {
1605
+//       for (let childId of shape.children) {
1606
+//         const childShape = shapes[childId]
1607
+//         getShapeUtils(childShape).translateBy(childShape, deltaForShape)
1608
+//       }
1609
+//     }
1610
+//   })
1611
+// }
1612
+
1613
+export function updateParents(data: Data, changedShapeIds: string[]) {
1614
+  if (changedShapeIds.length === 0) return
1615
+
1616
+  const { shapes } = getPage(data)
1617
+
1618
+  const parentToUpdateIds = Array.from(
1619
+    new Set(changedShapeIds.map((id) => shapes[id].parentId).values())
1620
+  ).filter((id) => id !== data.currentPageId)
1621
+
1622
+  for (const parentId of parentToUpdateIds) {
1623
+    const parent = shapes[parentId] as GroupShape
1624
+    getShapeUtils(parent).onChildrenChange(
1625
+      parent,
1626
+      parent.children.map((id) => shapes[id])
1627
+    )
1628
+  }
1629
+
1630
+  updateParents(data, parentToUpdateIds)
1631
+}
1632
+
1633
+export function getParentOffset(data: Data, shapeId: string, offset = [0, 0]) {
1634
+  const shape = getShape(data, shapeId)
1635
+  return shape.parentId === data.currentPageId
1636
+    ? offset
1637
+    : getParentOffset(data, shape.parentId, vec.add(offset, shape.point))
1638
+}
1639
+
1640
+export function getParentRotation(
1641
+  data: Data,
1642
+  shapeId: string,
1643
+  rotation = 0
1644
+): number {
1645
+  const shape = getShape(data, shapeId)
1646
+  return shape.parentId === data.currentPageId
1647
+    ? rotation + shape.rotation
1648
+    : getParentRotation(data, shape.parentId, rotation + shape.rotation)
1649
+}

Loading…
Cancel
Save