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