Adds groups
This commit is contained in:
parent
e8509e1f84
commit
506eecc95f
34 changed files with 1114 additions and 207 deletions
|
@ -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)`}
|
||||
>
|
||||
<CenterHandle bounds={bounds} isLocked={isAllLocked} />
|
||||
{!isAllLocked && (
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -62,7 +62,7 @@ export default function Canvas() {
|
|||
<g ref={rGroup}>
|
||||
<BoundsBg />
|
||||
<Page />
|
||||
<Selected />
|
||||
{/* <Selected /> */}
|
||||
<Bounds />
|
||||
<Handles />
|
||||
<Brush />
|
||||
|
|
|
@ -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 (
|
||||
<g pointerEvents={isSelecting ? 'all' : 'none'}>
|
||||
{currentPageShapeIds.map((shapeId) => (
|
||||
<Shape key={shapeId} id={shapeId} isSelecting={isSelecting} />
|
||||
<Shape
|
||||
key={shapeId}
|
||||
id={shapeId}
|
||||
isSelecting={isSelecting}
|
||||
parentPoint={noOffset}
|
||||
parentRotation={0}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
|
|
|
@ -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 (
|
||||
<g>
|
||||
{currentPageShapeIds.map((id) => (
|
||||
<ShapeOutline key={id} id={id} isSelected={selectedIds.has(id)} />
|
||||
{currentSelectedShapeIds.map((id) => (
|
||||
<ShapeOutline key={id} id={id} />
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export const ShapeOutline = memo(function ShapeOutline({
|
||||
id,
|
||||
isSelected,
|
||||
}: {
|
||||
id: string
|
||||
isSelected: boolean
|
||||
}) {
|
||||
export const ShapeOutline = memo(function ShapeOutline({ id }: { id: string }) {
|
||||
const rIndicator = useRef<SVGUseElement>(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',
|
||||
|
|
|
@ -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<SVGGElement>(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 (
|
||||
<StyledGroup ref={rGroup} transform={transform}>
|
||||
{isSelecting && (
|
||||
<HoverIndicator
|
||||
as="use"
|
||||
href={'#' + id}
|
||||
strokeWidth={+style.strokeWidth + 4}
|
||||
variant={getShapeUtils(shape).canStyleFill ? 'filled' : 'hollow'}
|
||||
{...events}
|
||||
/>
|
||||
)}
|
||||
{!shape.isHidden && <RealShape id={id} style={style} />}
|
||||
</StyledGroup>
|
||||
<>
|
||||
<StyledGroup ref={rGroup} transform={transform}>
|
||||
{isSelecting && !isGroup && (
|
||||
<HoverIndicator
|
||||
as="use"
|
||||
href={'#' + id}
|
||||
strokeWidth={+style.strokeWidth + 4}
|
||||
variant={getShapeUtils(shape).canStyleFill ? 'filled' : 'hollow'}
|
||||
{...events}
|
||||
/>
|
||||
)}
|
||||
{!shape.isHidden && <StyledShape as="use" href={'#' + id} {...style} />}
|
||||
{isGroup &&
|
||||
shape.children.map((shapeId) => (
|
||||
<Shape
|
||||
key={shapeId}
|
||||
id={shapeId}
|
||||
isSelecting={isSelecting}
|
||||
parentPoint={shape.point}
|
||||
parentRotation={shape.rotation}
|
||||
/>
|
||||
))}
|
||||
</StyledGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const RealShape = memo(function RealShape({
|
||||
id,
|
||||
style,
|
||||
}: {
|
||||
id: string
|
||||
style: ReturnType<typeof getShapeStyle>
|
||||
}) {
|
||||
return <StyledShape as="use" href={'#' + id} {...style} />
|
||||
})
|
||||
|
||||
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
|
||||
y={4}
|
||||
x={4}
|
||||
fontSize={18}
|
||||
fontSize={12}
|
||||
fill="black"
|
||||
stroke="none"
|
||||
alignmentBaseline="text-before-edge"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{text}
|
||||
{children}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
@ -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(', ') + ']'
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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<SVGElement>
|
||||
) {
|
||||
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]
|
||||
)
|
||||
|
|
|
@ -248,7 +248,7 @@ const arrow = registerShapeUtils<ArrowShape>({
|
|||
return this
|
||||
},
|
||||
|
||||
onHandleMove(shape, handles) {
|
||||
onHandleChange(shape, handles) {
|
||||
for (let id in handles) {
|
||||
const handle = handles[id]
|
||||
|
||||
|
|
193
lib/shape-utils/group.tsx
Normal file
193
lib/shape-utils/group.tsx
Normal file
|
@ -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<GroupShape>({
|
||||
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 (
|
||||
<g id={id}>
|
||||
<StyledGroupShape id={id} width={size[0]} height={size[1]} />
|
||||
</g>
|
||||
)
|
||||
},
|
||||
|
||||
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
|
|
@ -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<K extends Shape> {
|
|||
style: Partial<ShapeStyles>
|
||||
): ShapeUtility<K>
|
||||
|
||||
translateBy(
|
||||
this: ShapeUtility<K>,
|
||||
shape: Mutable<K>,
|
||||
point: number[]
|
||||
): ShapeUtility<K>
|
||||
|
||||
translateTo(
|
||||
this: ShapeUtility<K>,
|
||||
shape: Mutable<K>,
|
||||
point: number[]
|
||||
): ShapeUtility<K>
|
||||
|
||||
// Transform to fit a new bounding box when more than one shape is selected.
|
||||
transform(
|
||||
this: ShapeUtility<K>,
|
||||
|
@ -97,15 +112,22 @@ export interface ShapeUtility<K extends Shape> {
|
|||
value: K[P]
|
||||
): ShapeUtility<K>
|
||||
|
||||
// Respond when any child of this shape changes.
|
||||
onChildrenChange(
|
||||
this: ShapeUtility<K>,
|
||||
shape: Mutable<K>,
|
||||
children: Shape[]
|
||||
): ShapeUtility<K>
|
||||
|
||||
// Respond when a user moves one of the shape's bound elements.
|
||||
onBindingMove?(
|
||||
onBindingChange(
|
||||
this: ShapeUtility<K>,
|
||||
shape: Mutable<K>,
|
||||
bindings: Record<string, ShapeBinding>
|
||||
): ShapeUtility<K>
|
||||
|
||||
// Respond when a user moves one of the shape's handles.
|
||||
onHandleMove?(
|
||||
onHandleChange(
|
||||
this: ShapeUtility<K>,
|
||||
shape: Mutable<K>,
|
||||
handle: Partial<K['handles']>
|
||||
|
@ -142,6 +164,7 @@ const shapeUtilityMap: Record<ShapeType, ShapeUtility<Shape>> = {
|
|||
[ShapeType.Draw]: draw,
|
||||
[ShapeType.Arrow]: arrow,
|
||||
[ShapeType.Text]: arrow,
|
||||
[ShapeType.Group]: group,
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -180,6 +203,16 @@ function getDefaultShapeUtil<T extends Shape>(): ShapeUtility<T> {
|
|||
return <circle id={shape.id} />
|
||||
},
|
||||
|
||||
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<T extends Shape>(): ShapeUtility<T> {
|
|||
return this.transform(shape, bounds, info)
|
||||
},
|
||||
|
||||
onBindingMove() {
|
||||
onChildrenChange() {
|
||||
return this
|
||||
},
|
||||
|
||||
onHandleMove() {
|
||||
onBindingChange() {
|
||||
return this
|
||||
},
|
||||
|
||||
onHandleChange() {
|
||||
return this
|
||||
},
|
||||
|
||||
|
@ -258,11 +295,11 @@ export function registerShapeUtils<K extends Shape>(
|
|||
return Object.freeze({ ...getDefaultShapeUtil<K>(), ...shapeUtil })
|
||||
}
|
||||
|
||||
export function createShape<T extends Shape>(
|
||||
type: T['type'],
|
||||
props: Partial<T>
|
||||
) {
|
||||
return shapeUtilityMap[type].create(props) as T
|
||||
export function createShape<T extends ShapeType>(
|
||||
type: T,
|
||||
props: Partial<ShapeByType<T>>
|
||||
): ShapeByType<T> {
|
||||
return shapeUtilityMap[type].create(props) as ShapeByType<T>
|
||||
}
|
||||
|
||||
export default shapeUtilityMap
|
||||
|
|
|
@ -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,
|
||||
])
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
])
|
||||
|
|
136
state/commands/group.ts
Normal file
136
state/commands/group.ts
Normal file
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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))
|
||||
},
|
||||
})
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
|
102
state/commands/ungroup.ts
Normal file
102
state/commands/ungroup.ts
Normal file
|
@ -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)
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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<string, PointerInfo> = {}
|
||||
|
||||
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]]
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<typeof getBrushSnapshot>
|
||||
|
||||
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]
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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),
|
||||
})),
|
||||
}
|
||||
|
|
138
state/state.ts
138
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)
|
||||
)
|
||||
}
|
||||
|
|
30
types.ts
30
types.ts
|
@ -26,6 +26,7 @@ export interface Data {
|
|||
pointedId?: string
|
||||
hoveredId?: string
|
||||
currentPageId: string
|
||||
currentParentId: string
|
||||
currentCodeFileId: string
|
||||
codeControls: Record<string, CodeControl>
|
||||
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<SVGUseElement> & {
|
||||
// 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<string, ShapeBinding>
|
||||
handles?: Record<string, ShapeHandle>
|
||||
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<MutableShape>
|
||||
| GroupShape
|
||||
|
||||
export interface Shapes {
|
||||
[ShapeType.Dot]: Readonly<DotShape>
|
||||
|
@ -214,8 +210,11 @@ export interface Shapes {
|
|||
[ShapeType.Rectangle]: Readonly<RectangleShape>
|
||||
[ShapeType.Arrow]: Readonly<ArrowShape>
|
||||
[ShapeType.Text]: Readonly<TextShape>
|
||||
[ShapeType.Group]: Readonly<GroupShape>
|
||||
}
|
||||
|
||||
export type Shape = Readonly<MutableShape>
|
||||
|
||||
export type ShapeByType<T extends ShapeType> = 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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue