diff --git a/components/canvas/bounds/bounding-box.tsx b/components/canvas/bounds/bounding-box.tsx
index 481037048..c4dcf503d 100644
--- a/components/canvas/bounds/bounding-box.tsx
+++ b/components/canvas/bounds/bounding-box.tsx
@@ -3,6 +3,7 @@ import { Edge, Corner } from 'types'
import { useSelector } from 'state'
import {
deepCompareArrays,
+ getBoundsCenter,
getCurrentCamera,
getPage,
getSelectedShapes,
@@ -58,7 +59,8 @@ export default function Bounds() {
rotate(${rotation * (180 / Math.PI)},
${(bounds.minX + bounds.maxX) / 2},
${(bounds.minY + bounds.maxY) / 2})
- translate(${bounds.minX},${bounds.minY})`}
+ translate(${bounds.minX},${bounds.minY})
+ rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)`}
>
{!isAllLocked && (
diff --git a/components/canvas/bounds/bounds-bg.tsx b/components/canvas/bounds/bounds-bg.tsx
index 1de16c675..fb8a053d8 100644
--- a/components/canvas/bounds/bounds-bg.tsx
+++ b/components/canvas/bounds/bounds-bg.tsx
@@ -66,7 +66,8 @@ export default function BoundsBg() {
rotate(${rotation * (180 / Math.PI)},
${(bounds.minX + bounds.maxX) / 2},
${(bounds.minY + bounds.maxY) / 2})
- translate(${bounds.minX},${bounds.minY})`}
+ translate(${bounds.minX},${bounds.minY})
+ rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)`}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
/>
diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx
index bb1394286..cc27d15f3 100644
--- a/components/canvas/canvas.tsx
+++ b/components/canvas/canvas.tsx
@@ -62,7 +62,7 @@ export default function Canvas() {
-
+ {/* */}
diff --git a/components/canvas/page.tsx b/components/canvas/page.tsx
index ed0ab79fa..27e9a0306 100644
--- a/components/canvas/page.tsx
+++ b/components/canvas/page.tsx
@@ -1,4 +1,5 @@
import { useSelector } from 'state'
+import { GroupShape } from 'types'
import { deepCompareArrays, getPage } from 'utils/utils'
import Shape from './shape'
@@ -8,9 +9,12 @@ on the current page. Kind of expensive but only happens
here; and still cheaper than any other pattern I've found.
*/
+const noOffset = [0, 0]
+
export default function Page() {
const currentPageShapeIds = useSelector(({ data }) => {
return Object.values(getPage(data).shapes)
+ .filter((shape) => shape.parentId === data.currentPageId)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
}, deepCompareArrays)
@@ -20,7 +24,13 @@ export default function Page() {
return (
{currentPageShapeIds.map((shapeId) => (
-
+
))}
)
diff --git a/components/canvas/selected.tsx b/components/canvas/selected.tsx
index a6281c057..b64f7e97b 100644
--- a/components/canvas/selected.tsx
+++ b/components/canvas/selected.tsx
@@ -4,11 +4,11 @@ import { deepCompareArrays, getPage } from 'utils/utils'
import { getShapeUtils } from 'lib/shape-utils'
import useShapeEvents from 'hooks/useShapeEvents'
import { memo, useRef } from 'react'
+import { ShapeType } from 'types'
+import * as vec from 'utils/vec'
export default function Selected() {
- const selectedIds = useSelector((s) => s.data.selectedIds)
-
- const currentPageShapeIds = useSelector(({ data }) => {
+ const currentSelectedShapeIds = useSelector(({ data }) => {
return Array.from(data.selectedIds.values())
}, deepCompareArrays)
@@ -18,32 +18,33 @@ export default function Selected() {
return (
- {currentPageShapeIds.map((id) => (
-
+ {currentSelectedShapeIds.map((id) => (
+
))}
)
}
-export const ShapeOutline = memo(function ShapeOutline({
- id,
- isSelected,
-}: {
- id: string
- isSelected: boolean
-}) {
+export const ShapeOutline = memo(function ShapeOutline({ id }: { id: string }) {
const rIndicator = useRef(null)
- const shape = useSelector(({ data }) => getPage(data).shapes[id])
+ const shape = useSelector((s) => getPage(s.data).shapes[id])
- const events = useShapeEvents(id, rIndicator)
+ const events = useShapeEvents(id, shape?.type === ShapeType.Group, rIndicator)
if (!shape) return null
+ // This needs computation from state, similar to bounds, in order
+ // to handle parent rotation.
+
+ const center = getShapeUtils(shape).getCenter(shape)
+ const bounds = getShapeUtils(shape).getBounds(shape)
+
const transform = `
- rotate(${shape.rotation * (180 / Math.PI)},
- ${getShapeUtils(shape).getCenter(shape)})
- translate(${shape.point})
+ rotate(${shape.rotation * (180 / Math.PI)},
+ ${center})
+ translate(${bounds.minX},${bounds.minY})
+ rotate(${(bounds.rotation || 0) * (180 / Math.PI)}, 0, 0)
`
return (
@@ -59,7 +60,7 @@ export const ShapeOutline = memo(function ShapeOutline({
})
const SelectIndicator = styled('path', {
- zStrokeWidth: 3,
+ zStrokeWidth: 1,
strokeLineCap: 'round',
strokeLinejoin: 'round',
stroke: '$selected',
diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx
index 691783359..a6f8de2c6 100644
--- a/components/canvas/shape.tsx
+++ b/components/canvas/shape.tsx
@@ -3,16 +3,24 @@ import { useSelector } from 'state'
import styled from 'styles'
import { getShapeUtils } from 'lib/shape-utils'
import { getPage } from 'utils/utils'
-import { DashStyle, ShapeStyles } from 'types'
+import { ShapeType } from 'types'
import useShapeEvents from 'hooks/useShapeEvents'
+import * as vec from 'utils/vec'
import { getShapeStyle } from 'lib/shape-styles'
-function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
+interface ShapeProps {
+ id: string
+ isSelecting: boolean
+ parentPoint: number[]
+ parentRotation: number
+}
+
+function Shape({ id, isSelecting, parentPoint, parentRotation }: ShapeProps) {
const shape = useSelector(({ data }) => getPage(data).shapes[id])
const rGroup = useRef(null)
- const events = useShapeEvents(id, rGroup)
+ const events = useShapeEvents(id, shape?.type === ShapeType.Group, rGroup)
// This is a problem with deleted shapes. The hooks in this component
// may sometimes run before the hook in the Page component, which means
@@ -20,41 +28,45 @@ function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
// detects the change and pulls this component.
if (!shape) return null
+ const isGroup = shape.type === ShapeType.Group
+
const center = getShapeUtils(shape).getCenter(shape)
const transform = `
- rotate(${shape.rotation * (180 / Math.PI)}, ${center})
- translate(${shape.point})
+ rotate(${shape.rotation * (180 / Math.PI)}, ${vec.sub(center, parentPoint)})
+ translate(${vec.sub(shape.point, parentPoint)})
`
const style = getShapeStyle(shape.style)
return (
-
- {isSelecting && (
-
- )}
- {!shape.isHidden && }
-
+ <>
+
+ {isSelecting && !isGroup && (
+
+ )}
+ {!shape.isHidden && }
+ {isGroup &&
+ shape.children.map((shapeId) => (
+
+ ))}
+
+ >
)
}
-const RealShape = memo(function RealShape({
- id,
- style,
-}: {
- id: string
- style: ReturnType
-}) {
- return
-})
-
const StyledShape = styled('path', {
strokeLinecap: 'round',
strokeLinejoin: 'round',
@@ -109,18 +121,18 @@ const StyledGroup = styled('g', {
},
})
-function Label({ text }: { text: string }) {
+function Label({ children }: { children: React.ReactNode }) {
return (
- {text}
+ {children}
)
}
@@ -128,3 +140,7 @@ function Label({ text }: { text: string }) {
export { HoverIndicator }
export default memo(Shape)
+
+function pp(n: number[]) {
+ return '[' + n.map((v) => v.toFixed(1)).join(', ') + ']'
+}
diff --git a/hooks/useKeyboardEvents.ts b/hooks/useKeyboardEvents.ts
index e4f64b964..5e4a46832 100644
--- a/hooks/useKeyboardEvents.ts
+++ b/hooks/useKeyboardEvents.ts
@@ -122,6 +122,16 @@ export default function useKeyboardEvents() {
state.send('DELETED', getKeyboardEventInfo(e))
break
}
+ case 'g': {
+ if (metaKey(e)) {
+ if (e.shiftKey) {
+ state.send('UNGROUPED', getKeyboardEventInfo(e))
+ } else {
+ state.send('GROUPED', getKeyboardEventInfo(e))
+ }
+ }
+ break
+ }
case 's': {
if (metaKey(e)) {
state.send('SAVED', getKeyboardEventInfo(e))
diff --git a/hooks/useShapeEvents.ts b/hooks/useShapeEvents.ts
index c27832590..71d8ef03d 100644
--- a/hooks/useShapeEvents.ts
+++ b/hooks/useShapeEvents.ts
@@ -1,17 +1,23 @@
-import { MutableRefObject, useCallback } from 'react'
+import { MutableRefObject, useCallback, useRef } from 'react'
import state from 'state'
import inputs from 'state/inputs'
export default function useShapeEvents(
id: string,
+ isGroup: boolean,
rGroup: MutableRefObject
) {
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
+ if (isGroup) return
if (!inputs.canAccept(e.pointerId)) return
- // e.stopPropagation()
+ e.stopPropagation()
rGroup.current.setPointerCapture(e.pointerId)
- state.send('POINTED_SHAPE', inputs.pointerDown(e, id))
+ if (inputs.isDoubleClick()) {
+ state.send('DOUBLE_POINTED_SHAPE', inputs.pointerDown(e, id))
+ } else {
+ state.send('POINTED_SHAPE', inputs.pointerDown(e, id))
+ }
},
[id]
)
@@ -19,7 +25,7 @@ export default function useShapeEvents(
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
- // e.stopPropagation()
+ e.stopPropagation()
rGroup.current.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', inputs.pointerUp(e))
},
@@ -29,7 +35,11 @@ export default function useShapeEvents(
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
- state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id))
+ if (isGroup) {
+ state.send('HOVERED_GROUP', inputs.pointerEnter(e, id))
+ } else {
+ state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id))
+ }
},
[id]
)
@@ -37,7 +47,11 @@ export default function useShapeEvents(
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
- state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id))
+ if (isGroup) {
+ state.send('MOVED_OVER_GROUP', inputs.pointerEnter(e, id))
+ } else {
+ state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id))
+ }
},
[id]
)
@@ -45,7 +59,11 @@ export default function useShapeEvents(
const handlePointerLeave = useCallback(
(e: React.PointerEvent) => {
if (!inputs.canAccept(e.pointerId)) return
- state.send('UNHOVERED_SHAPE', { target: id })
+ if (isGroup) {
+ state.send('UNHOVERED_GROUP', { target: id })
+ } else {
+ state.send('UNHOVERED_SHAPE', { target: id })
+ }
},
[id]
)
diff --git a/lib/shape-utils/arrow.tsx b/lib/shape-utils/arrow.tsx
index 32237285a..bd7a1b10c 100644
--- a/lib/shape-utils/arrow.tsx
+++ b/lib/shape-utils/arrow.tsx
@@ -248,7 +248,7 @@ const arrow = registerShapeUtils({
return this
},
- onHandleMove(shape, handles) {
+ onHandleChange(shape, handles) {
for (let id in handles) {
const handle = handles[id]
diff --git a/lib/shape-utils/group.tsx b/lib/shape-utils/group.tsx
new file mode 100644
index 000000000..c693c76f1
--- /dev/null
+++ b/lib/shape-utils/group.tsx
@@ -0,0 +1,193 @@
+import { v4 as uuid } from 'uuid'
+import * as vec from 'utils/vec'
+import {
+ GroupShape,
+ RectangleShape,
+ ShapeType,
+ Bounds,
+ Corner,
+ Edge,
+} from 'types'
+import { getShapeUtils, registerShapeUtils } from './index'
+import {
+ getBoundsCenter,
+ getCommonBounds,
+ getRotatedCorners,
+ rotateBounds,
+ translateBounds,
+} from 'utils/utils'
+import { defaultStyle, getShapeStyle } from 'lib/shape-styles'
+import styled from 'styles'
+import { boundsContainPolygon } from 'utils/bounds'
+
+const group = registerShapeUtils({
+ boundsCache: new WeakMap([]),
+
+ create(props) {
+ return {
+ id: uuid(),
+ type: ShapeType.Group,
+ isGenerated: false,
+ name: 'Rectangle',
+ parentId: 'page0',
+ childIndex: 0,
+ point: [0, 0],
+ size: [1, 1],
+ radius: 2,
+ rotation: 0,
+ isAspectRatioLocked: false,
+ isLocked: false,
+ isHidden: false,
+ style: defaultStyle,
+ children: [],
+ ...props,
+ }
+ },
+
+ render(shape) {
+ const { id, size } = shape
+
+ return (
+
+
+
+ )
+ },
+
+ translateTo(shape, point) {
+ shape.point = point
+ return this
+ },
+
+ getBounds(shape) {
+ if (!this.boundsCache.has(shape)) {
+ const [width, height] = shape.size
+ const bounds = {
+ minX: 0,
+ maxX: width,
+ minY: 0,
+ maxY: height,
+ width,
+ height,
+ }
+
+ this.boundsCache.set(shape, bounds)
+ }
+
+ return translateBounds(this.boundsCache.get(shape), shape.point)
+ },
+
+ hitTest() {
+ return false
+ },
+
+ hitTestBounds(shape, brushBounds) {
+ return false
+ },
+
+ transform(shape, bounds, { initialShape, transformOrigin, scaleX, scaleY }) {
+ if (shape.rotation === 0 && !shape.isAspectRatioLocked) {
+ shape.size = [bounds.width, bounds.height]
+ shape.point = [bounds.minX, bounds.minY]
+ } else {
+ shape.size = vec.mul(
+ initialShape.size,
+ Math.min(Math.abs(scaleX), Math.abs(scaleY))
+ )
+
+ shape.point = [
+ bounds.minX +
+ (bounds.width - shape.size[0]) *
+ (scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
+ bounds.minY +
+ (bounds.height - shape.size[1]) *
+ (scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
+ ]
+
+ shape.rotation =
+ (scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
+ ? -initialShape.rotation
+ : initialShape.rotation
+ }
+
+ return this
+ },
+
+ transformSingle(shape, bounds) {
+ shape.size = [bounds.width, bounds.height]
+ shape.point = [bounds.minX, bounds.minY]
+ return this
+ },
+
+ onChildrenChange(shape, children) {
+ const childBounds = getCommonBounds(
+ ...children.map((child) => getShapeUtils(child).getRotatedBounds(child))
+ )
+
+ // const c1 = this.getCenter(shape)
+ // const c2 = getBoundsCenter(childBounds)
+
+ // const [x0, y0] = vec.rotWith(shape.point, c1, shape.rotation)
+ // const [w0, h0] = vec.rotWith(shape.size, c1, shape.rotation)
+ // const [x1, y1] = vec.rotWith(
+ // [childBounds.minX, childBounds.minY],
+ // c2,
+ // shape.rotation
+ // )
+ // const [w1, h1] = vec.rotWith(
+ // [childBounds.width, childBounds.height],
+ // c2,
+ // shape.rotation
+ // )
+
+ // let delta: number[]
+
+ // if (h0 === h1 && w0 !== w1) {
+ // if (x0 < x1) {
+ // // moving left edge, pin right edge
+ // delta = vec.sub([x1 + w1, y1 + h1 / 2], [x0 + w0, y0 + h0 / 2])
+ // } else {
+ // // moving right edge, pin left edge
+ // delta = vec.sub([x1, y1 + h1 / 2], [x0, y0 + h0 / 2])
+ // }
+ // } else if (h0 !== h1 && w0 === w1) {
+ // if (y0 < y1) {
+ // // moving top edge, pin bottom edge
+ // delta = vec.sub([x1 + w1 / 2, y1 + h1], [x0 + w0 / 2, y0 + h0])
+ // } else {
+ // // moving bottom edge, pin top edge
+ // delta = vec.sub([x1 + w1 / 2, y1], [x0 + w0 / 2, y0])
+ // }
+ // } else if (x0 !== x1) {
+ // if (y0 !== y1) {
+ // // moving top left, pin bottom right
+ // delta = vec.sub([x1 + w1, y1 + h1], [x0 + w0, y0 + h0])
+ // } else {
+ // // moving bottom left, pin top right
+ // delta = vec.sub([x1 + w1, y1], [x0 + w0, y0])
+ // }
+ // } else if (y0 !== y1) {
+ // // moving top right, pin bottom left
+ // delta = vec.sub([x1, y1 + h1], [x0, y0 + h0])
+ // } else {
+ // // moving bottom right, pin top left
+ // delta = vec.sub([x1, y1], [x0, y0])
+ // }
+
+ // if (shape.rotation !== 0) {
+ // shape.point = vec.sub(shape.point, delta)
+ // }
+
+ shape.point = [childBounds.minX, childBounds.minY] //vec.add([x1, y1], delta)
+ shape.size = [childBounds.width, childBounds.height]
+
+ return this
+ },
+})
+
+const StyledGroupShape = styled('rect', {
+ zDash: 5,
+ zStrokeWidth: 1,
+})
+
+export default group
diff --git a/lib/shape-utils/index.tsx b/lib/shape-utils/index.tsx
index 53c0d62a8..55905a36e 100644
--- a/lib/shape-utils/index.tsx
+++ b/lib/shape-utils/index.tsx
@@ -7,17 +7,9 @@ import {
ShapeStyles,
ShapeBinding,
Mutable,
+ ShapeByType,
} from 'types'
-import { v4 as uuid } from 'uuid'
-import circle from './circle'
-import dot from './dot'
-import polyline from './polyline'
-import rectangle from './rectangle'
-import ellipse from './ellipse'
-import line from './line'
-import ray from './ray'
-import draw from './draw'
-import arrow from './arrow'
+import * as vec from 'utils/vec'
import {
getBoundsCenter,
getBoundsFromPoints,
@@ -28,6 +20,17 @@ import {
boundsContainPolygon,
pointInBounds,
} from 'utils/bounds'
+import { v4 as uuid } from 'uuid'
+import circle from './circle'
+import dot from './dot'
+import polyline from './polyline'
+import rectangle from './rectangle'
+import ellipse from './ellipse'
+import line from './line'
+import ray from './ray'
+import draw from './draw'
+import arrow from './arrow'
+import group from './group'
/*
Shape Utiliies
@@ -62,6 +65,18 @@ export interface ShapeUtility {
style: Partial
): ShapeUtility
+ translateBy(
+ this: ShapeUtility,
+ shape: Mutable,
+ point: number[]
+ ): ShapeUtility
+
+ translateTo(
+ this: ShapeUtility,
+ shape: Mutable,
+ point: number[]
+ ): ShapeUtility
+
// Transform to fit a new bounding box when more than one shape is selected.
transform(
this: ShapeUtility,
@@ -97,15 +112,22 @@ export interface ShapeUtility {
value: K[P]
): ShapeUtility
+ // Respond when any child of this shape changes.
+ onChildrenChange(
+ this: ShapeUtility,
+ shape: Mutable,
+ children: Shape[]
+ ): ShapeUtility
+
// Respond when a user moves one of the shape's bound elements.
- onBindingMove?(
+ onBindingChange(
this: ShapeUtility,
shape: Mutable,
bindings: Record
): ShapeUtility
// Respond when a user moves one of the shape's handles.
- onHandleMove?(
+ onHandleChange(
this: ShapeUtility,
shape: Mutable,
handle: Partial
@@ -142,6 +164,7 @@ const shapeUtilityMap: Record> = {
[ShapeType.Draw]: draw,
[ShapeType.Arrow]: arrow,
[ShapeType.Text]: arrow,
+ [ShapeType.Group]: group,
}
/**
@@ -180,6 +203,16 @@ function getDefaultShapeUtil(): ShapeUtility {
return
},
+ translateBy(shape, delta) {
+ shape.point = vec.add(shape.point, delta)
+ return this
+ },
+
+ translateTo(shape, point) {
+ shape.point = point
+ return this
+ },
+
transform(shape, bounds) {
shape.point = [bounds.minX, bounds.minY]
return this
@@ -189,11 +222,15 @@ function getDefaultShapeUtil(): ShapeUtility {
return this.transform(shape, bounds, info)
},
- onBindingMove() {
+ onChildrenChange() {
return this
},
- onHandleMove() {
+ onBindingChange() {
+ return this
+ },
+
+ onHandleChange() {
return this
},
@@ -258,11 +295,11 @@ export function registerShapeUtils(
return Object.freeze({ ...getDefaultShapeUtil(), ...shapeUtil })
}
-export function createShape(
- type: T['type'],
- props: Partial
-) {
- return shapeUtilityMap[type].create(props) as T
+export function createShape(
+ type: T,
+ props: Partial>
+): ShapeByType {
+ return shapeUtilityMap[type].create(props) as ShapeByType
}
export default shapeUtilityMap
diff --git a/state/commands/align.ts b/state/commands/align.ts
index d61bb31af..ba7417d1f 100644
--- a/state/commands/align.ts
+++ b/state/commands/align.ts
@@ -27,7 +27,7 @@ export default function alignCommand(data: Data, type: AlignType) {
case AlignType.Top: {
for (let id in boundsForShapes) {
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
+ getShapeUtils(shape).translateTo(shape, [
shape.point[0],
commonBounds.minY,
])
@@ -37,7 +37,7 @@ export default function alignCommand(data: Data, type: AlignType) {
case AlignType.CenterVertical: {
for (let id in boundsForShapes) {
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
+ getShapeUtils(shape).translateTo(shape, [
shape.point[0],
midY - boundsForShapes[id].height / 2,
])
@@ -47,7 +47,7 @@ export default function alignCommand(data: Data, type: AlignType) {
case AlignType.Bottom: {
for (let id in boundsForShapes) {
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
+ getShapeUtils(shape).translateTo(shape, [
shape.point[0],
commonBounds.maxY - boundsForShapes[id].height,
])
@@ -57,7 +57,7 @@ export default function alignCommand(data: Data, type: AlignType) {
case AlignType.Left: {
for (let id in boundsForShapes) {
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
+ getShapeUtils(shape).translateTo(shape, [
commonBounds.minX,
shape.point[1],
])
@@ -67,7 +67,7 @@ export default function alignCommand(data: Data, type: AlignType) {
case AlignType.CenterHorizontal: {
for (let id in boundsForShapes) {
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
+ getShapeUtils(shape).translateTo(shape, [
midX - boundsForShapes[id].width / 2,
shape.point[1],
])
@@ -77,7 +77,7 @@ export default function alignCommand(data: Data, type: AlignType) {
case AlignType.Right: {
for (let id in boundsForShapes) {
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
+ getShapeUtils(shape).translateTo(shape, [
commonBounds.maxX - boundsForShapes[id].width,
shape.point[1],
])
@@ -91,7 +91,7 @@ export default function alignCommand(data: Data, type: AlignType) {
for (let id in boundsForShapes) {
const shape = shapes[id]
const initialBounds = boundsForShapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
+ getShapeUtils(shape).translateTo(shape, [
initialBounds.minX,
initialBounds.minY,
])
diff --git a/state/commands/delete-selected.ts b/state/commands/delete-selected.ts
index 40bae2571..d873b91c1 100644
--- a/state/commands/delete-selected.ts
+++ b/state/commands/delete-selected.ts
@@ -1,9 +1,10 @@
-import Command from "./command"
-import history from "../history"
-import { TranslateSnapshot } from "state/sessions/translate-session"
-import { Data } from "types"
-import { getPage } from "utils/utils"
-import { current } from "immer"
+import Command from './command'
+import history from '../history'
+import { TranslateSnapshot } from 'state/sessions/translate-session'
+import { Data } from 'types'
+import { getPage, updateParents } from 'utils/utils'
+import { current } from 'immer'
+import { getShapeUtils } from 'lib/shape-utils'
export default function deleteSelected(data: Data) {
const { currentPageId } = data
@@ -19,13 +20,27 @@ export default function deleteSelected(data: Data) {
history.execute(
data,
new Command({
- name: "delete_shapes",
- category: "canvas",
+ name: 'delete_shapes',
+ category: 'canvas',
manualSelection: true,
do(data) {
const page = getPage(data, currentPageId)
for (let id of selectedIds) {
+ const shape = page.shapes[id]
+ if (shape.parentId !== data.currentPageId) {
+ const parent = page.shapes[shape.parentId]
+ getShapeUtils(parent)
+ .setProperty(
+ parent,
+ 'children',
+ parent.children.filter((childId) => childId !== shape.id)
+ )
+ .onChildrenChange(
+ parent,
+ parent.children.map((id) => page.shapes[id])
+ )
+ }
delete page.shapes[id]
}
diff --git a/state/commands/distribute.ts b/state/commands/distribute.ts
index e6d38b825..0701f08fc 100644
--- a/state/commands/distribute.ts
+++ b/state/commands/distribute.ts
@@ -59,7 +59,7 @@ export default function distributeCommand(data: Data, type: DistributeType) {
for (let i = 0; i < entriesToMove.length; i++) {
const [id, bounds] = entriesToMove[i]
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
+ getShapeUtils(shape).translateTo(shape, [
x + step * i - bounds.width / 2,
bounds.minY,
])
@@ -75,10 +75,7 @@ export default function distributeCommand(data: Data, type: DistributeType) {
for (let i = 0; i < entriesToMove.length - 1; i++) {
const [id, bounds] = entriesToMove[i]
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
- x,
- bounds.minY,
- ])
+ getShapeUtils(shape).translateTo(shape, [x, bounds.minY])
x += bounds.width + step
}
}
@@ -104,7 +101,7 @@ export default function distributeCommand(data: Data, type: DistributeType) {
for (let i = 0; i < entriesToMove.length; i++) {
const [id, bounds] = entriesToMove[i]
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
+ getShapeUtils(shape).translateTo(shape, [
bounds.minX,
y + step * i - bounds.height / 2,
])
@@ -120,10 +117,7 @@ export default function distributeCommand(data: Data, type: DistributeType) {
for (let i = 0; i < entriesToMove.length - 1; i++) {
const [id, bounds] = entriesToMove[i]
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
- bounds.minX,
- y,
- ])
+ getShapeUtils(shape).translateTo(shape, [bounds.minX, y])
y += bounds.height + step
}
}
@@ -137,7 +131,7 @@ export default function distributeCommand(data: Data, type: DistributeType) {
for (let id in boundsForShapes) {
const shape = shapes[id]
const initialBounds = boundsForShapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', [
+ getShapeUtils(shape).translateTo(shape, [
initialBounds.minX,
initialBounds.minY,
])
diff --git a/state/commands/group.ts b/state/commands/group.ts
new file mode 100644
index 000000000..f45f1fbd8
--- /dev/null
+++ b/state/commands/group.ts
@@ -0,0 +1,136 @@
+import Command from './command'
+import history from '../history'
+import { Data, GroupShape, Shape, ShapeType } from 'types'
+import {
+ getCommonBounds,
+ getPage,
+ getSelectedShapes,
+ getShape,
+} from 'utils/utils'
+import { current } from 'immer'
+import { createShape, getShapeUtils } from 'lib/shape-utils'
+import { PropsOfType } from 'types'
+import { v4 as uuid } from 'uuid'
+import commands from '.'
+
+export default function groupCommand(data: Data) {
+ const cData = current(data)
+ const { currentPageId, selectedIds } = cData
+
+ const initialShapes = getSelectedShapes(cData).sort(
+ (a, b) => a.childIndex - b.childIndex
+ )
+
+ const isAllSameParent = initialShapes.every(
+ (shape, i) => i === 0 || shape.parentId === initialShapes[i - 1].parentId
+ )
+
+ let newGroupParentId: string
+ let newGroupShape: GroupShape
+ let oldGroupShape: GroupShape
+
+ const selectedShapeIds = initialShapes.map((s) => s.id)
+
+ const parentIds = Array.from(
+ new Set(initialShapes.map((s) => s.parentId)).values()
+ )
+
+ const commonBounds = getCommonBounds(
+ ...initialShapes.map((shape) =>
+ getShapeUtils(shape).getRotatedBounds(shape)
+ )
+ )
+
+ if (isAllSameParent) {
+ const parentId = initialShapes[0].parentId
+ if (parentId === currentPageId) {
+ newGroupParentId = currentPageId
+ } else {
+ // Are all of the parent's children selected?
+ const parent = getShape(data, parentId) as GroupShape
+
+ if (parent.children.length === initialShapes.length) {
+ // !
+ // !
+ // !
+ // Hey! We're not going any further. We need to ungroup those shapes.
+ commands.ungroup(data)
+ return
+ } else {
+ newGroupParentId = parentId
+ }
+ }
+ } else {
+ // Find the least-deep parent among the shapes and add the group as a child
+ let minDepth = Infinity
+
+ for (let parentId of initialShapes.map((shape) => shape.parentId)) {
+ const depth = getShapeDepth(data, parentId)
+ if (depth < minDepth) {
+ minDepth = depth
+ newGroupParentId = parentId
+ }
+ }
+ }
+
+ newGroupShape = createShape(ShapeType.Group, {
+ parentId: newGroupParentId,
+ point: [commonBounds.minX, commonBounds.minY],
+ size: [commonBounds.width, commonBounds.height],
+ children: selectedShapeIds,
+ })
+
+ history.execute(
+ data,
+ new Command({
+ name: 'group_shapes',
+ category: 'canvas',
+ do(data) {
+ const { shapes } = getPage(data, currentPageId)
+
+ // Remove shapes from old parents
+ for (const parentId of parentIds) {
+ if (parentId === currentPageId) continue
+
+ const shape = shapes[parentId] as GroupShape
+ getShapeUtils(shape).setProperty(
+ shape,
+ 'children',
+ shape.children.filter((id) => !selectedIds.has(id))
+ )
+ }
+
+ shapes[newGroupShape.id] = newGroupShape
+ data.selectedIds.clear()
+ data.selectedIds.add(newGroupShape.id)
+ initialShapes.forEach(({ id }, i) => {
+ const shape = shapes[id]
+ getShapeUtils(shape)
+ .setProperty(shape, 'parentId', newGroupShape.id)
+ .setProperty(shape, 'childIndex', i)
+ })
+ },
+ undo(data) {
+ const { shapes } = getPage(data, currentPageId)
+ data.selectedIds.clear()
+
+ delete shapes[newGroupShape.id]
+ initialShapes.forEach(({ id, parentId, childIndex }, i) => {
+ data.selectedIds.add(id)
+ const shape = shapes[id]
+ getShapeUtils(shape)
+ .setProperty(shape, 'parentId', parentId)
+ .setProperty(shape, 'childIndex', childIndex)
+ })
+ },
+ })
+ )
+}
+
+function getShapeDepth(data: Data, id: string, depth = 0) {
+ if (id === data.currentPageId) {
+ return depth
+ }
+
+ return getShapeDepth(data, getShape(data, id).parentId, depth + 1)
+}
diff --git a/state/commands/handle.ts b/state/commands/handle.ts
index 950b7acd2..84ab5edd1 100644
--- a/state/commands/handle.ts
+++ b/state/commands/handle.ts
@@ -22,14 +22,14 @@ export default function handleCommand(
const shape = getPage(data, currentPageId).shapes[initialShape.id]
- getShapeUtils(shape).onHandleMove(shape, initialShape.handles)
+ getShapeUtils(shape).onHandleChange(shape, initialShape.handles)
},
undo(data) {
const { initialShape, currentPageId } = before
const shape = getPage(data, currentPageId).shapes[initialShape.id]
- getShapeUtils(shape).onHandleMove(shape, initialShape.handles)
+ getShapeUtils(shape).onHandleChange(shape, initialShape.handles)
},
})
)
diff --git a/state/commands/index.ts b/state/commands/index.ts
index 650893ba8..bc99cccff 100644
--- a/state/commands/index.ts
+++ b/state/commands/index.ts
@@ -20,6 +20,8 @@ import transform from './transform'
import transformSingle from './transform-single'
import translate from './translate'
import handle from './handle'
+import group from './group'
+import ungroup from './ungroup'
const commands = {
align,
@@ -44,6 +46,8 @@ const commands = {
transformSingle,
translate,
handle,
+ group,
+ ungroup,
}
export default commands
diff --git a/state/commands/transform-single.ts b/state/commands/transform-single.ts
index c98dcc3b3..c8df36fe2 100644
--- a/state/commands/transform-single.ts
+++ b/state/commands/transform-single.ts
@@ -4,7 +4,7 @@ import { Data, Corner, Edge } from 'types'
import { getShapeUtils } from 'lib/shape-utils'
import { current } from 'immer'
import { TransformSingleSnapshot } from 'state/sessions/transform-single-session'
-import { getPage } from 'utils/utils'
+import { getPage, updateParents } from 'utils/utils'
export default function transformSingleCommand(
data: Data,
@@ -29,6 +29,8 @@ export default function transformSingleCommand(
data.selectedIds.add(id)
shapes[id] = shape
+
+ updateParents(data, [id])
},
undo(data) {
const { id, type, initialShapeBounds } = before
@@ -50,6 +52,7 @@ export default function transformSingleCommand(
scaleY: 1,
transformOrigin: [0.5, 0.5],
})
+ updateParents(data, [id])
}
},
})
diff --git a/state/commands/transform.ts b/state/commands/transform.ts
index 760f60c04..bd9184dc0 100644
--- a/state/commands/transform.ts
+++ b/state/commands/transform.ts
@@ -3,7 +3,7 @@ import history from '../history'
import { Data } from 'types'
import { TransformSnapshot } from 'state/sessions/transform-session'
import { getShapeUtils } from 'lib/shape-utils'
-import { getPage } from 'utils/utils'
+import { getPage, updateParents } from 'utils/utils'
export default function transformCommand(
data: Data,
@@ -37,6 +37,8 @@ export default function transformCommand(
scaleY,
})
}
+
+ updateParents(data, Object.keys(shapeBounds))
},
undo(data) {
const { type, shapeBounds } = before
@@ -56,6 +58,8 @@ export default function transformCommand(
scaleY: scaleX < 0 ? scaleX * -1 : scaleX,
})
}
+
+ updateParents(data, Object.keys(shapeBounds))
},
})
)
diff --git a/state/commands/translate.ts b/state/commands/translate.ts
index 148efab54..7e8b03e3b 100644
--- a/state/commands/translate.ts
+++ b/state/commands/translate.ts
@@ -2,7 +2,7 @@ import Command from './command'
import history from '../history'
import { TranslateSnapshot } from 'state/sessions/translate-session'
import { Data } from 'types'
-import { getPage } from 'utils/utils'
+import { getPage, updateParents } from 'utils/utils'
import { getShapeUtils } from 'lib/shape-utils'
export default function translateCommand(
@@ -22,39 +22,63 @@ export default function translateCommand(
const { initialShapes, currentPageId } = after
const { shapes } = getPage(data, currentPageId)
- const { clones } = before // !
-
- data.selectedIds.clear()
+ // Restore clones to document
if (isCloning) {
- for (const clone of clones) {
+ for (const clone of before.clones) {
shapes[clone.id] = clone
+ if (clone.parentId !== data.currentPageId) {
+ const parent = shapes[clone.parentId]
+ getShapeUtils(parent).setProperty(parent, 'children', [
+ ...parent.children,
+ clone.id,
+ ])
+ }
}
}
+ // Move shapes (these initialShapes will include clones if any)
for (const { id, point } of initialShapes) {
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', point)
- data.selectedIds.add(id)
+ getShapeUtils(shape).translateTo(shape, point)
}
+
+ // Set selected shapes
+ data.selectedIds = new Set(initialShapes.map((s) => s.id))
+
+ // Update parents
+ updateParents(
+ data,
+ initialShapes.map((s) => s.id)
+ )
},
undo(data) {
- const { initialShapes, clones, currentPageId } = before
+ const { initialShapes, clones, currentPageId, initialParents } = before
const { shapes } = getPage(data, currentPageId)
- data.selectedIds.clear()
-
- if (isCloning) {
- for (const { id } of clones) {
- delete shapes[id]
- }
- }
-
+ // Move shapes back to where they started
for (const { id, point } of initialShapes) {
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', point)
- data.selectedIds.add(id)
+ getShapeUtils(shape).translateTo(shape, point)
}
+
+ // Delete clones
+ if (isCloning) for (const { id } of clones) delete shapes[id]
+
+ // Set selected shapes
+ data.selectedIds = new Set(initialShapes.map((s) => s.id))
+
+ // Restore children on parents
+ initialParents.forEach(({ id, children }) => {
+ const parent = shapes[id]
+ getShapeUtils(parent).setProperty(parent, 'children', children)
+ })
+
+ // Update parents
+ updateParents(
+ data,
+ initialShapes.map((s) => s.id)
+ )
},
})
)
diff --git a/state/commands/ungroup.ts b/state/commands/ungroup.ts
new file mode 100644
index 000000000..0b36efcc9
--- /dev/null
+++ b/state/commands/ungroup.ts
@@ -0,0 +1,102 @@
+import Command from './command'
+import history from '../history'
+import { Data, GroupShape, Shape, ShapeType } from 'types'
+import {
+ getCommonBounds,
+ getPage,
+ getSelectedShapes,
+ getShape,
+} from 'utils/utils'
+import { current } from 'immer'
+import { createShape, getShapeUtils } from 'lib/shape-utils'
+import { PropsOfType } from 'types'
+import { v4 as uuid } from 'uuid'
+
+export default function ungroupCommand(data: Data) {
+ const cData = current(data)
+ const { currentPageId, selectedIds } = cData
+
+ const selectedGroups = getSelectedShapes(cData)
+ .filter((shape) => shape.type === ShapeType.Group)
+ .sort((a, b) => a.childIndex - b.childIndex)
+
+ // Are all of the shapes already in the same group?
+ // - ungroup the shapes
+ // Otherwise...
+ // - remove the shapes from any existing group and add them to a new one
+
+ history.execute(
+ data,
+ new Command({
+ name: 'ungroup_shapes',
+ category: 'canvas',
+ do(data) {
+ const { shapes } = getPage(data)
+
+ // Remove shapes from old parents
+ for (const oldGroupShape of selectedGroups) {
+ const siblings = (
+ oldGroupShape.parentId === currentPageId
+ ? Object.values(shapes).filter(
+ (shape) => shape.parentId === currentPageId
+ )
+ : shapes[oldGroupShape.parentId].children.map((id) => shapes[id])
+ ).sort((a, b) => a.childIndex - b.childIndex)
+
+ const trueIndex = siblings.findIndex((s) => s.id === oldGroupShape.id)
+
+ let step: number
+
+ if (trueIndex === siblings.length - 1) {
+ step = 1
+ } else {
+ step =
+ (siblings[trueIndex + 1].childIndex - oldGroupShape.childIndex) /
+ (oldGroupShape.children.length + 1)
+ }
+
+ data.selectedIds.clear()
+
+ // Move shapes to page
+ oldGroupShape.children
+ .map((id) => shapes[id])
+ .forEach(({ id }, i) => {
+ const shape = shapes[id]
+ data.selectedIds.add(id)
+ getShapeUtils(shape)
+ .setProperty(shape, 'parentId', oldGroupShape.parentId)
+ .setProperty(
+ shape,
+ 'childIndex',
+ oldGroupShape.childIndex + step * i
+ )
+ })
+
+ delete shapes[oldGroupShape.id]
+ }
+ },
+ undo(data) {
+ const { shapes } = getPage(data, currentPageId)
+ selectedIds.clear()
+ selectedGroups.forEach((group) => {
+ selectedIds.add(group.id)
+ shapes[group.id] = group
+ group.children.forEach((id, i) => {
+ const shape = shapes[id]
+ getShapeUtils(shape)
+ .setProperty(shape, 'parentId', group.id)
+ .setProperty(shape, 'childIndex', i)
+ })
+ })
+ },
+ })
+ )
+}
+
+function getShapeDepth(data: Data, id: string, depth = 0) {
+ if (id === data.currentPageId) {
+ return depth
+ }
+
+ return getShapeDepth(data, getShape(data, id).parentId, depth + 1)
+}
diff --git a/state/data.ts b/state/data.ts
index d4ad873a0..299abf710 100644
--- a/state/data.ts
+++ b/state/data.ts
@@ -114,18 +114,32 @@ export const defaultDocument: Data['document'] = {
// strokeWidth: 1,
// },
// }),
- // shape1: shapeUtils[ShapeType.Rectangle].create({
- // id: 'shape1',
- // name: 'Shape 1',
- // childIndex: 1,
- // point: [400, 600],
- // size: [200, 200],
- // style: {
- // stroke: shades.black,
- // fill: shades.lightGray,
- // strokeWidth: 1,
- // },
- // }),
+ // Groups Testing
+ shapeA: shapeUtils[ShapeType.Rectangle].create({
+ id: 'shapeA',
+ name: 'Shape A',
+ childIndex: 1,
+ point: [0, 0],
+ size: [200, 200],
+ parentId: 'groupA',
+ }),
+ shapeB: shapeUtils[ShapeType.Rectangle].create({
+ id: 'shapeB',
+ name: 'Shape B',
+ childIndex: 2,
+ point: [220, 100],
+ size: [200, 200],
+ parentId: 'groupA',
+ }),
+ groupA: shapeUtils[ShapeType.Group].create({
+ id: 'groupA',
+ name: 'Group A',
+ childIndex: 2,
+ point: [0, 0],
+ size: [420, 300],
+ parentId: 'page1',
+ children: ['shapeA', 'shapeB'],
+ }),
},
},
page2: {
diff --git a/state/inputs.tsx b/state/inputs.tsx
index 91ad32f0e..eefdddc2f 100644
--- a/state/inputs.tsx
+++ b/state/inputs.tsx
@@ -2,8 +2,11 @@ import React from 'react'
import { PointerInfo } from 'types'
import { isDarwin } from 'utils/utils'
+const DOUBLE_CLICK_DURATION = 300
+
class Inputs {
activePointerId?: number
+ lastPointerDownTime = 0
points: Record = {}
touchStart(e: TouchEvent | React.TouchEvent, target: string) {
@@ -128,6 +131,7 @@ class Inputs {
delete this.points[e.pointerId]
delete this.activePointerId
+ this.lastPointerDownTime = Date.now()
return info
}
@@ -143,6 +147,10 @@ class Inputs {
)
}
+ isDoubleClick() {
+ return Date.now() - this.lastPointerDownTime < DOUBLE_CLICK_DURATION
+ }
+
get pointer() {
return this.points[Object.keys(this.points)[0]]
}
diff --git a/state/sessions/arrow-session.ts b/state/sessions/arrow-session.ts
index a6f05dac2..b8c5f4d67 100644
--- a/state/sessions/arrow-session.ts
+++ b/state/sessions/arrow-session.ts
@@ -3,7 +3,7 @@ import * as vec from 'utils/vec'
import BaseSession from './base-session'
import commands from 'state/commands'
import { current } from 'immer'
-import { getBoundsFromPoints, getPage } from 'utils/utils'
+import { getBoundsFromPoints, getPage, updateParents } from 'utils/utils'
import { getShapeUtils } from 'lib/shape-utils'
export default class PointsSession extends BaseSession {
@@ -51,12 +51,14 @@ export default class PointsSession extends BaseSession {
const shape = getPage(data).shapes[id] as ArrowShape
- getShapeUtils(shape).onHandleMove(shape, {
+ getShapeUtils(shape).onHandleChange(shape, {
end: {
...shape.handles.end,
point: vec.sub(point, shape.point),
},
})
+
+ updateParents(data, [shape])
}
cancel(data: Data) {
@@ -65,8 +67,10 @@ export default class PointsSession extends BaseSession {
const shape = getPage(data).shapes[id] as ArrowShape
getShapeUtils(shape)
- .onHandleMove(shape, { end: initialShape.handles.end })
+ .onHandleChange(shape, { end: initialShape.handles.end })
.setProperty(shape, 'point', initialShape.point)
+
+ updateParents(data, [shape])
}
complete(data: Data) {
@@ -96,7 +100,7 @@ export default class PointsSession extends BaseSession {
])
.setProperty(shape, 'handles', nextHandles)
.setProperty(shape, 'point', newPoint)
- .onHandleMove(shape, nextHandles)
+ .onHandleChange(shape, nextHandles)
commands.arrow(
data,
diff --git a/state/sessions/brush-session.ts b/state/sessions/brush-session.ts
index 18a35ef4e..2decde220 100644
--- a/state/sessions/brush-session.ts
+++ b/state/sessions/brush-session.ts
@@ -1,8 +1,8 @@
import { current } from 'immer'
-import { Bounds, Data } from 'types'
+import { Bounds, Data, ShapeType } from 'types'
import BaseSession from './base-session'
import { getShapeUtils } from 'lib/shape-utils'
-import { getBoundsFromPoints, getShapes } from 'utils/utils'
+import { getBoundsFromPoints, getPage, getShapes } from 'utils/utils'
import * as vec from 'utils/vec'
export default class BrushSession extends BaseSession {
@@ -23,13 +23,24 @@ export default class BrushSession extends BaseSession {
const brushBounds = getBoundsFromPoints([origin, point])
for (let id in snapshot.shapeHitTests) {
- const test = snapshot.shapeHitTests[id]
+ const { test, selectId } = snapshot.shapeHitTests[id]
if (test(brushBounds)) {
- if (!data.selectedIds.has(id)) {
- data.selectedIds.add(id)
+ // When brushing a shape, select its top group parent.
+ if (!data.selectedIds.has(selectId)) {
+ data.selectedIds.add(selectId)
}
- } else if (data.selectedIds.has(id)) {
- data.selectedIds.delete(id)
+
+ // Possibly... select all of the top group parent's children too?
+ // const selectedId = getTopParentId(data, id)
+ // const idsToSelect = collectChildIds(data, selectedId)
+
+ // for (let id in idsToSelect) {
+ // if (!data.selectedIds.has(id)) {
+ // data.selectedIds.add(id)
+ // }
+ // }
+ } else if (data.selectedIds.has(selectId)) {
+ data.selectedIds.delete(selectId)
}
}
@@ -55,12 +66,39 @@ export function getBrushSnapshot(data: Data) {
return {
selectedIds: new Set(data.selectedIds),
shapeHitTests: Object.fromEntries(
- getShapes(current(data)).map((shape) => [
- shape.id,
- (bounds: Bounds) => getShapeUtils(shape).hitTestBounds(shape, bounds),
- ])
+ getShapes(current(data))
+ .filter((shape) => shape.type !== ShapeType.Group)
+ .map((shape) => [
+ shape.id,
+ {
+ selectId: getTopParentId(data, shape.id),
+ test: (bounds: Bounds) =>
+ getShapeUtils(shape).hitTestBounds(shape, bounds),
+ },
+ ])
),
}
}
export type BrushSnapshot = ReturnType
+
+function getTopParentId(data: Data, id: string): string {
+ const shape = getPage(data).shapes[id]
+ return shape.parentId === data.currentPageId ||
+ shape.parentId === data.currentParentId
+ ? id
+ : getTopParentId(data, shape.parentId)
+}
+
+function collectChildIds(data: Data, id: string): string[] {
+ const shape = getPage(data).shapes[id]
+
+ if (shape.type === ShapeType.Group) {
+ return [
+ id,
+ ...shape.children.flatMap((childId) => collectChildIds(data, childId)),
+ ]
+ }
+
+ return [id]
+}
diff --git a/state/sessions/draw-session.ts b/state/sessions/draw-session.ts
index 5058b51a0..37f268b38 100644
--- a/state/sessions/draw-session.ts
+++ b/state/sessions/draw-session.ts
@@ -2,7 +2,7 @@ import { current } from 'immer'
import { Data, DrawShape } from 'types'
import BaseSession from './base-session'
import { getShapeUtils } from 'lib/shape-utils'
-import { getPage } from 'utils/utils'
+import { getPage, getShape, updateParents } from 'utils/utils'
import * as vec from 'utils/vec'
import commands from 'state/commands'
@@ -25,7 +25,7 @@ export default class BrushSession extends BaseSession {
const page = getPage(data)
const shape = page.shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', point)
+ getShapeUtils(shape).translateTo(shape, point)
}
update = (data: Data, point: number[], isLocked = false) => {
@@ -73,17 +73,16 @@ export default class BrushSession extends BaseSession {
this.points.push(next)
this.previous = point
- const page = getPage(data)
- const shape = page.shapes[snapshot.id] as DrawShape
-
+ const shape = getShape(data, snapshot.id) as DrawShape
getShapeUtils(shape).setProperty(shape, 'points', [...this.points])
+ updateParents(data, [shape])
}
cancel = (data: Data) => {
const { snapshot } = this
- const page = getPage(data)
- const shape = page.shapes[snapshot.id] as DrawShape
+ const shape = getShape(data, snapshot.id) as DrawShape
getShapeUtils(shape).setProperty(shape, 'points', snapshot.points)
+ updateParents(data, [shape])
}
complete = (data: Data) => {
diff --git a/state/sessions/handle-session.ts b/state/sessions/handle-session.ts
index c7a5902ae..5d72e4036 100644
--- a/state/sessions/handle-session.ts
+++ b/state/sessions/handle-session.ts
@@ -33,7 +33,7 @@ export default class HandleSession extends BaseSession {
const handles = initialShape.handles
- getShapeUtils(shape).onHandleMove(shape, {
+ getShapeUtils(shape).onHandleChange(shape, {
[handleId]: {
...handles[handleId],
point: vec.add(handles[handleId].point, delta),
diff --git a/state/sessions/rotate-session.ts b/state/sessions/rotate-session.ts
index 38c3eb458..4248ce477 100644
--- a/state/sessions/rotate-session.ts
+++ b/state/sessions/rotate-session.ts
@@ -11,6 +11,7 @@ import {
getSelectedShapes,
getRotatedBounds,
getShapeBounds,
+ updateParents,
} from 'utils/utils'
import { getShapeUtils } from 'lib/shape-utils'
@@ -63,17 +64,28 @@ export default class RotateSession extends BaseSession {
.setProperty(shape, 'rotation', (PI2 + nextRotation) % PI2)
.setProperty(shape, 'point', nextPoint)
}
+
+ updateParents(
+ data,
+ initialShapes.map((s) => s.id)
+ )
}
cancel(data: Data) {
- const page = getPage(data, this.snapshot.currentPageId)
+ const { currentPageId, initialShapes } = this.snapshot
+ const page = getPage(data, currentPageId)
- for (let { id, point, rotation } of this.snapshot.initialShapes) {
+ for (let { id, point, rotation } of initialShapes) {
const shape = page.shapes[id]
getShapeUtils(shape)
.setProperty(shape, 'rotation', rotation)
.setProperty(shape, 'point', point)
}
+
+ updateParents(
+ data,
+ initialShapes.map((s) => s.id)
+ )
}
complete(data: Data) {
diff --git a/state/sessions/transform-session.ts b/state/sessions/transform-session.ts
index ea607c648..1b291df9d 100644
--- a/state/sessions/transform-session.ts
+++ b/state/sessions/transform-session.ts
@@ -13,6 +13,7 @@ import {
getSelectedShapes,
getShapes,
getTransformedBoundingBox,
+ updateParents,
} from 'utils/utils'
export default class TransformSession extends BaseSession {
@@ -71,15 +72,17 @@ export default class TransformSession extends BaseSession {
transformOrigin,
})
}
+
+ updateParents(data, Object.keys(shapeBounds))
}
cancel(data: Data) {
const { currentPageId, shapeBounds } = this.snapshot
- const page = getPage(data, currentPageId)
+ const { shapes } = getPage(data, currentPageId)
for (let id in shapeBounds) {
- const shape = page.shapes[id]
+ const shape = shapes[id]
const { initialShape, initialShapeBounds, transformOrigin } =
shapeBounds[id]
@@ -91,6 +94,8 @@ export default class TransformSession extends BaseSession {
scaleY: 1,
transformOrigin,
})
+
+ updateParents(data, Object.keys(shapeBounds))
}
}
diff --git a/state/sessions/transform-single-session.ts b/state/sessions/transform-single-session.ts
index 27e617cbf..700b8fdb8 100644
--- a/state/sessions/transform-single-session.ts
+++ b/state/sessions/transform-single-session.ts
@@ -12,6 +12,7 @@ import {
getPage,
getShape,
getSelectedShapes,
+ updateParents,
} from 'utils/utils'
export default class TransformSingleSession extends BaseSession {
@@ -61,6 +62,8 @@ export default class TransformSingleSession extends BaseSession {
scaleY: this.scaleY,
transformOrigin: [0.5, 0.5],
})
+
+ updateParents(data, [id])
}
cancel(data: Data) {
@@ -76,6 +79,8 @@ export default class TransformSingleSession extends BaseSession {
scaleY: this.scaleY,
transformOrigin: [0.5, 0.5],
})
+
+ updateParents(data, [id])
}
complete(data: Data) {
diff --git a/state/sessions/translate-session.ts b/state/sessions/translate-session.ts
index 053bdf489..f5d7b22c7 100644
--- a/state/sessions/translate-session.ts
+++ b/state/sessions/translate-session.ts
@@ -1,10 +1,15 @@
-import { Data } from 'types'
+import { Data, GroupShape, ShapeType } from 'types'
import * as vec from 'utils/vec'
import BaseSession from './base-session'
import commands from 'state/commands'
import { current } from 'immer'
import { v4 as uuid } from 'uuid'
-import { getChildIndexAbove, getPage, getSelectedShapes } from 'utils/utils'
+import {
+ getChildIndexAbove,
+ getPage,
+ getSelectedShapes,
+ updateParents,
+} from 'utils/utils'
import { getShapeUtils } from 'lib/shape-utils'
export default class TranslateSession extends BaseSession {
@@ -20,7 +25,8 @@ export default class TranslateSession extends BaseSession {
}
update(data: Data, point: number[], isAligned: boolean, isCloning: boolean) {
- const { currentPageId, clones, initialShapes } = this.snapshot
+ const { currentPageId, clones, initialShapes, initialParents } =
+ this.snapshot
const { shapes } = getPage(data, currentPageId)
const delta = vec.vec(this.origin, point)
@@ -40,19 +46,33 @@ export default class TranslateSession extends BaseSession {
for (const { id, point } of initialShapes) {
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', point)
+ getShapeUtils(shape).translateTo(shape, point)
}
+ data.selectedIds.clear()
+
for (const clone of clones) {
- shapes[clone.id] = { ...clone }
data.selectedIds.add(clone.id)
+ shapes[clone.id] = { ...clone }
+ if (clone.parentId !== data.currentPageId) {
+ const parent = shapes[clone.parentId]
+ getShapeUtils(parent).setProperty(parent, 'children', [
+ ...parent.children,
+ clone.id,
+ ])
+ }
}
}
for (const { id, point } of clones) {
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', vec.add(point, delta))
+ getShapeUtils(shape).translateTo(shape, vec.add(point, delta))
}
+
+ updateParents(
+ data,
+ clones.map((c) => c.id)
+ )
} else {
if (this.isCloning) {
this.isCloning = false
@@ -65,27 +85,65 @@ export default class TranslateSession extends BaseSession {
for (const clone of clones) {
delete shapes[clone.id]
}
+
+ initialParents.forEach(
+ (parent) =>
+ ((shapes[parent.id] as GroupShape).children = parent.children)
+ )
}
- for (const { id, point } of initialShapes) {
- const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', vec.add(point, delta))
+ for (const initialShape of initialShapes) {
+ const shape = shapes[initialShape.id]
+ const next = vec.add(initialShape.point, delta)
+ const deltaForShape = vec.sub(next, shape.point)
+ getShapeUtils(shape).translateTo(shape, next)
+
+ if (shape.type === ShapeType.Group) {
+ for (let childId of shape.children) {
+ const childShape = shapes[childId]
+ getShapeUtils(childShape).translateBy(childShape, deltaForShape)
+ }
+ }
}
+
+ updateParents(
+ data,
+ initialShapes.map((s) => s.id)
+ )
}
}
cancel(data: Data) {
- const { initialShapes, clones, currentPageId } = this.snapshot
+ const { initialShapes, initialParents, clones, currentPageId } =
+ this.snapshot
const { shapes } = getPage(data, currentPageId)
for (const { id, point } of initialShapes) {
const shape = shapes[id]
- getShapeUtils(shape).setProperty(shape, 'point', point)
+ const deltaForShape = vec.sub(point, shape.point)
+ getShapeUtils(shape).translateTo(shape, point)
+
+ if (shape.type === ShapeType.Group) {
+ for (let childId of shape.children) {
+ const childShape = shapes[childId]
+ getShapeUtils(childShape).translateBy(childShape, deltaForShape)
+ }
+ }
}
for (const { id } of clones) {
delete shapes[id]
}
+
+ initialParents.forEach(({ id, children }) => {
+ const shape = shapes[id]
+ getShapeUtils(shape).setProperty(shape, 'children', children)
+ })
+
+ updateParents(
+ data,
+ initialShapes.map((s) => s.id)
+ )
}
complete(data: Data) {
@@ -102,16 +160,31 @@ export default class TranslateSession extends BaseSession {
export function getTranslateSnapshot(data: Data) {
const cData = current(data)
- const shapes = getSelectedShapes(cData).filter((shape) => !shape.isLocked)
- const hasUnlockedShapes = shapes.length > 0
+ const page = getPage(cData)
+ const selectedShapes = getSelectedShapes(cData).filter(
+ (shape) => !shape.isLocked
+ )
+ const hasUnlockedShapes = selectedShapes.length > 0
+
+ const parents = Array.from(
+ new Set(selectedShapes.map((s) => s.parentId)).values()
+ )
+ .filter((id) => id !== data.currentPageId)
+ .map((id) => page.shapes[id])
return {
hasUnlockedShapes,
currentPageId: data.currentPageId,
- initialShapes: shapes.map(({ id, point }) => ({ id, point })),
- clones: shapes.map((shape) => ({
+ initialParents: parents.map(({ id, children }) => ({ id, children })),
+ initialShapes: selectedShapes.map(({ id, point, parentId }) => ({
+ id,
+ point,
+ parentId,
+ })),
+ clones: selectedShapes.map((shape) => ({
...shape,
id: uuid(),
+ parentId: shape.parentId,
childIndex: getChildIndexAbove(cData, shape.id),
})),
}
diff --git a/state/state.ts b/state/state.ts
index 5797c8878..ba21ac1df 100644
--- a/state/state.ts
+++ b/state/state.ts
@@ -15,9 +15,15 @@ import {
getCurrentCamera,
getPage,
getSelectedBounds,
+ getSelectedShapes,
getShape,
screenToWorld,
setZoomCSS,
+ translateBounds,
+ getParentOffset,
+ getParentRotation,
+ rotateBounds,
+ getBoundsCenter,
} from 'utils/utils'
import {
Data,
@@ -62,6 +68,7 @@ const initialData: Data = {
hoveredId: null,
selectedIds: new Set([]),
currentPageId: 'page1',
+ currentParentId: 'page1',
currentCodeFileId: 'file0',
codeControls: {},
document: defaultDocument,
@@ -143,7 +150,7 @@ const state = createState({
SELECTED_RECTANGLE_TOOL: { unless: 'isReadOnly', to: 'rectangle' },
TOGGLED_CODE_PANEL_OPEN: 'toggleCodePanel',
TOGGLED_STYLE_PANEL_OPEN: 'toggleStylePanel',
- POINTED_CANVAS: 'closeStylePanel',
+ POINTED_CANVAS: ['closeStylePanel', 'clearCurrentParentId'],
CHANGED_STYLE: ['updateStyles', 'applyStylesToSelection'],
SELECTED_ALL: { to: 'selecting', do: 'selectAll' },
NUDGED: { do: 'nudgeSelection' },
@@ -170,11 +177,19 @@ const state = createState({
GENERATED_FROM_CODE: ['setCodeControls', 'setGeneratedShapes'],
TOGGLED_TOOL_LOCK: 'toggleToolLock',
MOVED: { if: 'hasSelection', do: 'moveSelection' },
- ALIGNED: { if: 'hasSelection', do: 'alignSelection' },
- STRETCHED: { if: 'hasSelection', do: 'stretchSelection' },
- DISTRIBUTED: { if: 'hasSelection', do: 'distributeSelection' },
DUPLICATED: { if: 'hasSelection', do: 'duplicateSelection' },
ROTATED_CCW: { if: 'hasSelection', do: 'rotateSelectionCcw' },
+ ALIGNED: { if: 'hasMultipleSelection', do: 'alignSelection' },
+ STRETCHED: { if: 'hasMultipleSelection', do: 'stretchSelection' },
+ DISTRIBUTED: {
+ if: 'hasMultipleSelection',
+ do: 'distributeSelection',
+ },
+ GROUPED: { if: 'hasMultipleSelection', do: 'groupSelection' },
+ UNGROUPED: {
+ if: ['hasSelection', 'selectionIncludesGroups'],
+ do: 'ungroupSelection',
+ },
},
initial: 'notPointing',
states: {
@@ -199,6 +214,14 @@ const state = createState({
else: { if: 'shapeIsHovered', do: 'clearHoveredId' },
},
UNHOVERED_SHAPE: 'clearHoveredId',
+ DOUBLE_POINTED_SHAPE: [
+ 'setDrilledPointedId',
+ 'clearSelectedIds',
+ 'pushPointedIdToSelectedIds',
+ {
+ to: 'pointingBounds',
+ },
+ ],
POINTED_SHAPE: [
{
if: 'isPressingMetaKey',
@@ -738,6 +761,9 @@ const state = createState({
hasSelection(data) {
return data.selectedIds.size > 0
},
+ hasMultipleSelection(data) {
+ return data.selectedIds.size > 1
+ },
isToolLocked(data) {
return data.settings.isToolLocked
},
@@ -747,6 +773,11 @@ const state = createState({
hasOnlyOnePage(data) {
return Object.keys(data.document.pages).length === 1
},
+ selectionIncludesGroups(data) {
+ return getSelectedShapes(data).some(
+ (shape) => shape.type === ShapeType.Group
+ )
+ },
},
actions: {
/* ---------------------- Pages --------------------- */
@@ -763,6 +794,7 @@ const state = createState({
/* --------------------- Shapes --------------------- */
createShape(data, payload, type: ShapeType) {
const shape = createShape(type, {
+ parentId: data.currentPageId,
point: screenToWorld(payload.point, data),
style: getCurrent(data.currentStyle),
})
@@ -1005,7 +1037,16 @@ const state = createState({
data.hoveredId = undefined
},
setPointedId(data, payload: PointerInfo) {
- data.pointedId = payload.target
+ data.pointedId = getPointedId(data, payload.target)
+ data.currentParentId = getParentId(data, data.pointedId)
+ },
+ setDrilledPointedId(data, payload: PointerInfo) {
+ data.pointedId = getDrilledPointedId(data, payload.target)
+ data.currentParentId = getParentId(data, data.pointedId)
+ },
+ clearCurrentParentId(data) {
+ data.currentParentId = data.currentPageId
+ data.pointedId = undefined
},
clearPointedId(data) {
data.pointedId = undefined
@@ -1050,6 +1091,12 @@ const state = createState({
rotateSelectionCcw(data) {
commands.rotateCcw(data)
},
+ groupSelection(data) {
+ commands.group(data)
+ },
+ ungroupSelection(data) {
+ commands.ungroup(data)
+ },
/* ---------------------- Tool ---------------------- */
@@ -1336,7 +1383,7 @@ const state = createState({
},
restoreSavedData(data) {
- history.load(data)
+ // history.load(data)
},
clearBoundsRotation(data) {
@@ -1365,14 +1412,46 @@ const state = createState({
return null
}
- const shapeUtils = getShapeUtils(shapes[0])
+ const shape = shapes[0]
+ const shapeUtils = getShapeUtils(shape)
+
if (!shapeUtils.canTransform) return null
- return shapeUtils.getBounds(shapes[0])
+
+ let bounds = shapeUtils.getBounds(shape)
+
+ let parentId = shape.parentId
+
+ while (parentId !== data.currentPageId) {
+ const parent = page.shapes[parentId]
+
+ bounds = rotateBounds(
+ bounds,
+ getBoundsCenter(getShapeUtils(parent).getBounds(parent)),
+ parent.rotation
+ )
+
+ bounds.rotation = parent.rotation
+
+ parentId = parent.parentId
+ }
+
+ return bounds
}
- return getCommonBounds(
- ...shapes.map((shape) => getShapeUtils(shape).getRotatedBounds(shape))
+ const commonBounds = getCommonBounds(
+ ...shapes.map((shape) => {
+ const parentOffset = getParentOffset(data, shape.id)
+ const parentRotation = getParentRotation(data, shape.id)
+ const bounds = getShapeUtils(shape).getRotatedBounds(shape)
+
+ return translateBounds(
+ rotateBounds(bounds, getBoundsCenter(bounds), parentRotation),
+ vec.neg(parentOffset)
+ )
+ })
)
+
+ return commonBounds
},
selectedStyle(data) {
const selectedIds = Array.from(data.selectedIds.values())
@@ -1415,3 +1494,42 @@ export const useSelector = createSelectorHook(state)
function getCameraZoom(zoom: number) {
return clamp(zoom, 0.1, 5)
}
+
+function getParentId(data: Data, id: string) {
+ const shape = getPage(data).shapes[id]
+ return shape.parentId
+}
+
+function getPointedId(data: Data, id: string) {
+ const shape = getPage(data).shapes[id]
+
+ return shape.parentId === data.currentParentId ||
+ shape.parentId === data.currentPageId
+ ? id
+ : getPointedId(data, shape.parentId)
+}
+
+function getDrilledPointedId(data: Data, id: string) {
+ const shape = getPage(data).shapes[id]
+ return shape.parentId === data.currentPageId ||
+ shape.parentId === data.pointedId ||
+ shape.parentId === data.currentParentId
+ ? id
+ : getDrilledPointedId(data, shape.parentId)
+}
+
+function hasPointedIdInChildren(data: Data, id: string, pointedId: string) {
+ const shape = getPage(data).shapes[id]
+
+ if (shape.type !== ShapeType.Group) {
+ return false
+ }
+
+ if (shape.children.includes(pointedId)) {
+ return true
+ }
+
+ return shape.children.some((childId) =>
+ hasPointedIdInChildren(data, childId, pointedId)
+ )
+}
diff --git a/types.ts b/types.ts
index 6d8c9785d..867210a14 100644
--- a/types.ts
+++ b/types.ts
@@ -26,6 +26,7 @@ export interface Data {
pointedId?: string
hoveredId?: string
currentPageId: string
+ currentParentId: string
currentCodeFileId: string
codeControls: Record
document: {
@@ -66,14 +67,9 @@ export enum ShapeType {
Draw = 'draw',
Arrow = 'arrow',
Text = 'text',
+ Group = 'group',
}
-// Consider:
-// Glob = "glob",
-// Spline = "spline",
-// Cubic = "cubic",
-// Conic = "conic",
-
export enum ColorStyle {
White = 'White',
LightGray = 'LightGray',
@@ -108,12 +104,6 @@ export type ShapeStyles = {
isFilled: boolean
}
-// export type ShapeStyles = Partial<
-// React.SVGProps & {
-// dash: DashStyle
-// }
-// >
-
export interface BaseShape {
id: string
type: ShapeType
@@ -122,10 +112,11 @@ export interface BaseShape {
isGenerated: boolean
name: string
point: number[]
+ style: ShapeStyles
rotation: number
+ children?: string[]
bindings?: Record
handles?: Record
- style: ShapeStyles
isLocked: boolean
isHidden: boolean
isAspectRatioLocked: boolean
@@ -189,6 +180,12 @@ export interface TextShape extends BaseShape {
text: string
}
+export interface GroupShape extends BaseShape {
+ type: ShapeType.Group
+ children: string[]
+ size: number[]
+}
+
export type MutableShape =
| DotShape
| CircleShape
@@ -200,8 +197,7 @@ export type MutableShape =
| RectangleShape
| ArrowShape
| TextShape
-
-export type Shape = Readonly
+ | GroupShape
export interface Shapes {
[ShapeType.Dot]: Readonly
@@ -214,8 +210,11 @@ export interface Shapes {
[ShapeType.Rectangle]: Readonly
[ShapeType.Arrow]: Readonly
[ShapeType.Text]: Readonly
+ [ShapeType.Group]: Readonly
}
+export type Shape = Readonly
+
export type ShapeByType = Shapes[T]
export interface CodeFile {
@@ -276,6 +275,7 @@ export interface Bounds {
maxY: number
width: number
height: number
+ rotation?: number
}
export interface RotatedBounds extends Bounds {
diff --git a/utils/utils.ts b/utils/utils.ts
index b9375dc20..11be0623a 100644
--- a/utils/utils.ts
+++ b/utils/utils.ts
@@ -1,6 +1,15 @@
import Vector from 'lib/code/vector'
import React from 'react'
-import { Data, Bounds, Edge, Corner, Shape, ShapeStyles } from 'types'
+import {
+ Data,
+ Bounds,
+ Edge,
+ Corner,
+ Shape,
+ ShapeStyles,
+ GroupShape,
+ ShapeType,
+} from 'types'
import * as vec from './vec'
import _isMobile from 'ismobilejs'
import { getShapeUtils } from 'lib/shape-utils'
@@ -1586,3 +1595,55 @@ export function isAngleBetween(a: number, b: number, c: number) {
export function getCurrentCamera(data: Data) {
return data.pageStates[data.currentPageId].camera
}
+
+// export function updateChildren(data: Data, changedShapes: Shape[]) {
+// if (changedShapes.length === 0) return
+// const { shapes } = getPage(data)
+
+// changedShapes.forEach((shape) => {
+// if (shape.type === ShapeType.Group) {
+// for (let childId of shape.children) {
+// const childShape = shapes[childId]
+// getShapeUtils(childShape).translateBy(childShape, deltaForShape)
+// }
+// }
+// })
+// }
+
+export function updateParents(data: Data, changedShapeIds: string[]) {
+ if (changedShapeIds.length === 0) return
+
+ const { shapes } = getPage(data)
+
+ const parentToUpdateIds = Array.from(
+ new Set(changedShapeIds.map((id) => shapes[id].parentId).values())
+ ).filter((id) => id !== data.currentPageId)
+
+ for (const parentId of parentToUpdateIds) {
+ const parent = shapes[parentId] as GroupShape
+ getShapeUtils(parent).onChildrenChange(
+ parent,
+ parent.children.map((id) => shapes[id])
+ )
+ }
+
+ updateParents(data, parentToUpdateIds)
+}
+
+export function getParentOffset(data: Data, shapeId: string, offset = [0, 0]) {
+ const shape = getShape(data, shapeId)
+ return shape.parentId === data.currentPageId
+ ? offset
+ : getParentOffset(data, shape.parentId, vec.add(offset, shape.point))
+}
+
+export function getParentRotation(
+ data: Data,
+ shapeId: string,
+ rotation = 0
+): number {
+ const shape = getShape(data, shapeId)
+ return shape.parentId === data.currentPageId
+ ? rotation + shape.rotation
+ : getParentRotation(data, shape.parentId, rotation + shape.rotation)
+}