Adds groups

This commit is contained in:
Steve Ruiz 2021-06-04 17:08:43 +01:00
parent e8509e1f84
commit 506eecc95f
34 changed files with 1114 additions and 207 deletions

View file

@ -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 && (

View file

@ -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}
/>

View file

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

View file

@ -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>
)

View file

@ -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',

View file

@ -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(', ') + ']'
}

View file

@ -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))

View file

@ -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]
)

View file

@ -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
View 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

View file

@ -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

View file

@ -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,
])

View file

@ -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]
}

View file

@ -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
View 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)
}

View file

@ -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)
},
})
)

View file

@ -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

View file

@ -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])
}
},
})

View file

@ -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))
},
})
)

View file

@ -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
View 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)
}

View file

@ -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: {

View file

@ -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]]
}

View file

@ -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,

View file

@ -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]
}

View file

@ -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) => {

View file

@ -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),

View file

@ -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) {

View file

@ -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))
}
}

View file

@ -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) {

View file

@ -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),
})),
}

View file

@ -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)
)
}

View file

@ -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 {

View file

@ -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)
}