diff --git a/components/canvas/bounds-bg.tsx b/components/canvas/bounds-bg.tsx deleted file mode 100644 index e0d70cd4e..000000000 --- a/components/canvas/bounds-bg.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useRef } from "react" -import state, { useSelector } from "state" -import inputs from "state/inputs" -import styled from "styles" - -export default function BoundsBg() { - const rBounds = useRef(null) - const bounds = useSelector((state) => state.values.selectedBounds) - const isSelecting = useSelector((s) => s.isIn("selecting")) - const rotation = useSelector((s) => { - if (s.data.selectedIds.size === 1) { - const { shapes } = s.data.document.pages[s.data.currentPageId] - const selected = Array.from(s.data.selectedIds.values())[0] - return shapes[selected].rotation - } else { - return 0 - } - }) - - if (!bounds) return null - if (!isSelecting) return null - - const { minX, minY, width, height } = bounds - - return ( - { - if (e.buttons !== 1) return - e.stopPropagation() - rBounds.current.setPointerCapture(e.pointerId) - state.send("POINTED_BOUNDS", inputs.pointerDown(e, "bounds")) - }} - transform={`rotate(${rotation * (180 / Math.PI)},${minX + width / 2}, ${ - minY + height / 2 - })`} - /> - ) -} - -const StyledBoundsBg = styled("rect", { - fill: "$boundsBg", -}) diff --git a/components/canvas/bounds.tsx b/components/canvas/bounds.tsx deleted file mode 100644 index 49de78052..000000000 --- a/components/canvas/bounds.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import state, { useSelector } from "state" -import styled from "styles" -import inputs from "state/inputs" -import { useRef } from "react" -import { TransformCorner, TransformEdge } from "types" -import { lerp } from "utils/utils" - -export default function Bounds() { - const isBrushing = useSelector((s) => s.isIn("brushSelecting")) - const isSelecting = useSelector((s) => s.isIn("selecting")) - const zoom = useSelector((s) => s.data.camera.zoom) - const bounds = useSelector((s) => s.values.selectedBounds) - const rotation = useSelector((s) => { - if (s.data.selectedIds.size === 1) { - const { shapes } = s.data.document.pages[s.data.currentPageId] - const selected = Array.from(s.data.selectedIds.values())[0] - return shapes[selected].rotation - } else { - return 0 - } - }) - - if (!bounds) return null - if (!isSelecting) return null - - let { minX, minY, maxX, maxY, width, height } = bounds - - const p = 4 / zoom - const cp = p * 2 - - return ( - - - - - - - - - - - - - ) -} - -function RotateHandle({ x, y, r }: { x: number; y: number; r: number }) { - const rRotateHandle = useRef(null) - - return ( - { - e.stopPropagation() - rRotateHandle.current.setPointerCapture(e.pointerId) - state.send("POINTED_ROTATE_HANDLE", inputs.pointerDown(e, "rotate")) - }} - onPointerUp={(e) => { - e.stopPropagation() - rRotateHandle.current.releasePointerCapture(e.pointerId) - rRotateHandle.current.replaceWith(rRotateHandle.current) - state.send("STOPPED_POINTING", inputs.pointerDown(e, "rotate")) - }} - /> - ) -} - -function Corner({ - x, - y, - width, - height, - corner, -}: { - x: number - y: number - width: number - height: number - corner: TransformCorner -}) { - const rCorner = useRef(null) - - return ( - - { - e.stopPropagation() - rCorner.current.setPointerCapture(e.pointerId) - state.send("POINTED_BOUNDS_CORNER", inputs.pointerDown(e, corner)) - }} - onPointerUp={(e) => { - e.stopPropagation() - rCorner.current.releasePointerCapture(e.pointerId) - rCorner.current.replaceWith(rCorner.current) - state.send("STOPPED_POINTING", inputs.pointerDown(e, corner)) - }} - /> - - ) -} - -function EdgeHorizontal({ - x, - y, - width, - height, - edge, -}: { - x: number - y: number - width: number - height: number - edge: TransformEdge.Top | TransformEdge.Bottom -}) { - const rEdge = useRef(null) - - return ( - { - e.stopPropagation() - rEdge.current.setPointerCapture(e.pointerId) - state.send("POINTED_BOUNDS_EDGE", inputs.pointerDown(e, edge)) - }} - onPointerUp={(e) => { - e.stopPropagation() - e.preventDefault() - state.send("STOPPED_POINTING", inputs.pointerUp(e)) - rEdge.current.releasePointerCapture(e.pointerId) - rEdge.current.replaceWith(rEdge.current) - }} - edge={edge} - /> - ) -} - -function EdgeVertical({ - x, - y, - width, - height, - edge, -}: { - x: number - y: number - width: number - height: number - edge: TransformEdge.Right | TransformEdge.Left -}) { - const rEdge = useRef(null) - - return ( - { - e.stopPropagation() - state.send("POINTED_BOUNDS_EDGE", inputs.pointerDown(e, edge)) - rEdge.current.setPointerCapture(e.pointerId) - }} - onPointerUp={(e) => { - e.stopPropagation() - state.send("STOPPED_POINTING", inputs.pointerUp(e)) - rEdge.current.releasePointerCapture(e.pointerId) - rEdge.current.replaceWith(rEdge.current) - }} - edge={edge} - /> - ) -} - -const StyledEdge = styled("rect", { - stroke: "none", - fill: "none", - variants: { - edge: { - bottom_edge: { cursor: "ns-resize" }, - right_edge: { cursor: "ew-resize" }, - top_edge: { cursor: "ns-resize" }, - left_edge: { cursor: "ew-resize" }, - }, - }, -}) - -const StyledCorner = styled("rect", { - stroke: "$bounds", - fill: "#fff", - zStrokeWidth: 2, - variants: { - corner: { - top_left_corner: { cursor: "nwse-resize" }, - top_right_corner: { cursor: "nesw-resize" }, - bottom_right_corner: { cursor: "nwse-resize" }, - bottom_left_corner: { cursor: "nesw-resize" }, - }, - }, -}) - -const StyledRotateHandle = styled("circle", { - stroke: "$bounds", - fill: "#fff", - zStrokeWidth: 2, - cursor: "grab", -}) - -const StyledBounds = styled("rect", { - fill: "none", - stroke: "$bounds", - zStrokeWidth: 2, -}) diff --git a/components/canvas/bounds/bounding-box.tsx b/components/canvas/bounds/bounding-box.tsx new file mode 100644 index 000000000..21fd40dd8 --- /dev/null +++ b/components/canvas/bounds/bounding-box.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Edge, Corner } from "types" +import { useSelector } from "state" +import { getSelectedShapes, isMobile } from "utils/utils" + +import CenterHandle from "./center-handle" +import CornerHandle from "./corner-handle" +import EdgeHandle from "./edge-handle" +import RotateHandle from "./rotate-handle" + +export default function Bounds() { + const isBrushing = useSelector((s) => s.isIn("brushSelecting")) + const isSelecting = useSelector((s) => s.isIn("selecting")) + const zoom = useSelector((s) => s.data.camera.zoom) + const bounds = useSelector((s) => s.values.selectedBounds) + const rotation = useSelector(({ data }) => + data.selectedIds.size === 1 ? getSelectedShapes(data)[0].rotation : 0 + ) + + if (!bounds) return null + if (!isSelecting) return null + + const size = (isMobile().any ? 16 : 8) / zoom // Touch target size + + return ( + + + + + + + + + + + + + ) +} diff --git a/components/canvas/bounds/bounds-bg.tsx b/components/canvas/bounds/bounds-bg.tsx new file mode 100644 index 000000000..ca11e765a --- /dev/null +++ b/components/canvas/bounds/bounds-bg.tsx @@ -0,0 +1,58 @@ +import { useCallback, useRef } from "react" +import state, { useSelector } from "state" +import inputs from "state/inputs" +import styled from "styles" +import { getPage } from "utils/utils" + +function handlePointerDown(e: React.PointerEvent) { + if (e.buttons !== 1) return + e.stopPropagation() + e.currentTarget.setPointerCapture(e.pointerId) + state.send("POINTED_BOUNDS", inputs.pointerDown(e, "bounds")) +} + +function handlePointerUp(e: React.PointerEvent) { + if (e.buttons !== 1) return + e.stopPropagation() + e.currentTarget.releasePointerCapture(e.pointerId) + state.send("STOPPED_POINTING", inputs.pointerUp(e)) +} + +export default function BoundsBg() { + const rBounds = useRef(null) + const bounds = useSelector((state) => state.values.selectedBounds) + const isSelecting = useSelector((s) => s.isIn("selecting")) + const rotation = useSelector((s) => { + if (s.data.selectedIds.size === 1) { + const { shapes } = getPage(s.data) + const selected = Array.from(s.data.selectedIds.values())[0] + return shapes[selected].rotation + } else { + return 0 + } + }) + + if (!bounds) return null + if (!isSelecting) return null + + const { width, height } = bounds + + return ( + + ) +} + +const StyledBoundsBg = styled("rect", { + fill: "$boundsBg", +}) diff --git a/components/canvas/bounds/center-handle.tsx b/components/canvas/bounds/center-handle.tsx new file mode 100644 index 000000000..76e20ba97 --- /dev/null +++ b/components/canvas/bounds/center-handle.tsx @@ -0,0 +1,18 @@ +import styled from "styles" +import { Bounds } from "types" + +export default function CenterHandle({ bounds }: { bounds: Bounds }) { + return ( + + ) +} + +const StyledBounds = styled("rect", { + fill: "none", + stroke: "$bounds", + zStrokeWidth: 2, +}) diff --git a/components/canvas/bounds/corner-handle.tsx b/components/canvas/bounds/corner-handle.tsx new file mode 100644 index 000000000..41b357ce0 --- /dev/null +++ b/components/canvas/bounds/corner-handle.tsx @@ -0,0 +1,43 @@ +import useHandleEvents from "hooks/useBoundsHandleEvents" +import styled from "styles" +import { Corner, Bounds } from "types" + +export default function CornerHandle({ + size, + corner, + bounds, +}: { + size: number + bounds: Bounds + corner: Corner +}) { + const events = useHandleEvents(corner) + + const isTop = corner === Corner.TopLeft || corner === Corner.TopRight + const isLeft = corner === Corner.TopLeft || corner === Corner.BottomLeft + + return ( + + ) +} + +const StyledCorner = styled("rect", { + stroke: "$bounds", + fill: "#fff", + zStrokeWidth: 2, + variants: { + corner: { + [Corner.TopLeft]: { cursor: "nwse-resize" }, + [Corner.TopRight]: { cursor: "nesw-resize" }, + [Corner.BottomRight]: { cursor: "nwse-resize" }, + [Corner.BottomLeft]: { cursor: "nesw-resize" }, + }, + }, +}) diff --git a/components/canvas/bounds/edge-handle.tsx b/components/canvas/bounds/edge-handle.tsx new file mode 100644 index 000000000..1ddcc293a --- /dev/null +++ b/components/canvas/bounds/edge-handle.tsx @@ -0,0 +1,42 @@ +import useHandleEvents from "hooks/useBoundsHandleEvents" +import styled from "styles" +import { Edge, Bounds } from "types" + +export default function EdgeHandle({ + size, + bounds, + edge, +}: { + size: number + bounds: Bounds + edge: Edge +}) { + const events = useHandleEvents(edge) + + const isHorizontal = edge === Edge.Top || edge === Edge.Bottom + const isFarEdge = edge === Edge.Right || edge === Edge.Bottom + + return ( + + ) +} + +const StyledEdge = styled("rect", { + stroke: "none", + fill: "none", + variants: { + edge: { + [Edge.Top]: { cursor: "ns-resize" }, + [Edge.Right]: { cursor: "ew-resize" }, + [Edge.Bottom]: { cursor: "ns-resize" }, + [Edge.Left]: { cursor: "ew-resize" }, + }, + }, +}) diff --git a/components/canvas/bounds/rotate-handle.tsx b/components/canvas/bounds/rotate-handle.tsx new file mode 100644 index 000000000..d418acf6f --- /dev/null +++ b/components/canvas/bounds/rotate-handle.tsx @@ -0,0 +1,30 @@ +import useHandleEvents from "hooks/useBoundsHandleEvents" +import styled from "styles" +import { Bounds } from "types" + +export default function Rotate({ + bounds, + size, +}: { + bounds: Bounds + size: number +}) { + const events = useHandleEvents("rotate") + + return ( + + ) +} + +const StyledRotateHandle = styled("circle", { + stroke: "$bounds", + fill: "#fff", + zStrokeWidth: 2, + cursor: "grab", +}) diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 9ea69b248..cfd9f8b0c 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -5,8 +5,8 @@ import useCamera from "hooks/useCamera" import Page from "./page" import Brush from "./brush" import state from "state" -import Bounds from "./bounds" -import BoundsBg from "./bounds-bg" +import Bounds from "./bounds/bounding-box" +import BoundsBg from "./bounds/bounds-bg" import inputs from "state/inputs" export default function Canvas() { diff --git a/components/canvas/page.tsx b/components/canvas/page.tsx index 1e26c76bd..f4df0f85a 100644 --- a/components/canvas/page.tsx +++ b/components/canvas/page.tsx @@ -1,5 +1,5 @@ import { useSelector } from "state" -import { deepCompareArrays } from "utils/utils" +import { deepCompareArrays, getPage } from "utils/utils" import Shape from "./shape" /* @@ -10,7 +10,7 @@ here; and still cheaper than any other pattern I've found. export default function Page() { const currentPageShapeIds = useSelector( - ({ data }) => Object.keys(data.document.pages[data.currentPageId].shapes), + ({ data }) => Object.keys(getPage(data).shapes), deepCompareArrays ) diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx index cabc15746..fe30cc924 100644 --- a/components/canvas/shape.tsx +++ b/components/canvas/shape.tsx @@ -3,6 +3,7 @@ import state, { useSelector } from "state" import inputs from "state/inputs" import { getShapeUtils } from "lib/shape-utils" import styled from "styles" +import { getPage } from "utils/utils" function Shape({ id }: { id: string }) { const rGroup = useRef(null) @@ -11,9 +12,7 @@ function Shape({ id }: { id: string }) { const isSelected = useSelector((state) => state.values.selectedIds.has(id)) - const shape = useSelector( - ({ data }) => data.document.pages[data.currentPageId].shapes[id] - ) + const shape = useSelector(({ data }) => getPage(data).shapes[id]) const handlePointerDown = useCallback( (e: React.PointerEvent) => { diff --git a/hooks/useBoundsHandleEvents.ts b/hooks/useBoundsHandleEvents.ts new file mode 100644 index 000000000..3c2eae7a1 --- /dev/null +++ b/hooks/useBoundsHandleEvents.ts @@ -0,0 +1,38 @@ +import { useCallback, useRef } from "react" +import inputs from "state/inputs" +import { Edge, Corner } from "types" + +import state from "../state" + +export default function useBoundsHandleEvents( + handle: Edge | Corner | "rotate" +) { + const onPointerDown = useCallback( + (e) => { + if (e.buttons !== 1) return + e.stopPropagation() + e.currentTarget.setPointerCapture(e.pointerId) + state.send("POINTED_BOUNDS_HANDLE", inputs.pointerDown(e, handle)) + }, + [handle] + ) + + const onPointerMove = useCallback( + (e) => { + if (e.buttons !== 1) return + e.stopPropagation() + state.send("MOVED_POINTER", inputs.pointerMove(e)) + }, + [handle] + ) + + const onPointerUp = useCallback((e) => { + if (e.buttons !== 1) return + e.stopPropagation() + e.currentTarget.releasePointerCapture(e.pointerId) + e.currentTarget.replaceWith(e.currentTarget) + state.send("STOPPED_POINTING", inputs.pointerUp(e)) + }, []) + + return { onPointerDown, onPointerMove, onPointerUp } +} diff --git a/lib/shape-utils/circle.tsx b/lib/shape-utils/circle.tsx index 941fc9fcb..f03b05626 100644 --- a/lib/shape-utils/circle.tsx +++ b/lib/shape-utils/circle.tsx @@ -1,6 +1,6 @@ import { v4 as uuid } from "uuid" import * as vec from "utils/vec" -import { CircleShape, ShapeType, TransformCorner, TransformEdge } from "types" +import { CircleShape, ShapeType, Corner, Edge } from "types" import { registerShapeUtils } from "./index" import { boundsContained } from "utils/bounds" import { intersectCircleBounds } from "utils/intersections" @@ -99,7 +99,7 @@ const circle = registerShapeUtils({ // Set the new corner or position depending on the anchor switch (anchor) { - case TransformCorner.TopLeft: { + case Corner.TopLeft: { shape.radius = Math.min(bounds.width, bounds.height) / 2 shape.point = [ bounds.maxX - shape.radius * 2, @@ -107,12 +107,12 @@ const circle = registerShapeUtils({ ] break } - case TransformCorner.TopRight: { + case Corner.TopRight: { shape.radius = Math.min(bounds.width, bounds.height) / 2 shape.point = [bounds.minX, bounds.maxY - shape.radius * 2] break } - case TransformCorner.BottomRight: { + case Corner.BottomRight: { shape.radius = Math.min(bounds.width, bounds.height) / 2 shape.point = [ bounds.maxX - shape.radius * 2, @@ -121,12 +121,12 @@ const circle = registerShapeUtils({ break break } - case TransformCorner.BottomLeft: { + case Corner.BottomLeft: { shape.radius = Math.min(bounds.width, bounds.height) / 2 shape.point = [bounds.maxX - shape.radius * 2, bounds.minY] break } - case TransformEdge.Top: { + case Edge.Top: { shape.radius = bounds.height / 2 shape.point = [ bounds.minX + (bounds.width / 2 - shape.radius), @@ -134,7 +134,7 @@ const circle = registerShapeUtils({ ] break } - case TransformEdge.Right: { + case Edge.Right: { shape.radius = bounds.width / 2 shape.point = [ bounds.maxX - shape.radius * 2, @@ -142,7 +142,7 @@ const circle = registerShapeUtils({ ] break } - case TransformEdge.Bottom: { + case Edge.Bottom: { shape.radius = bounds.height / 2 shape.point = [ bounds.minX + (bounds.width / 2 - shape.radius), @@ -150,7 +150,7 @@ const circle = registerShapeUtils({ ] break } - case TransformEdge.Left: { + case Edge.Left: { shape.radius = bounds.width / 2 shape.point = [ bounds.minX, diff --git a/lib/shape-utils/index.tsx b/lib/shape-utils/index.tsx index 64efce3c8..d34bddcc8 100644 --- a/lib/shape-utils/index.tsx +++ b/lib/shape-utils/index.tsx @@ -4,8 +4,8 @@ import { Shape, Shapes, ShapeType, - TransformCorner, - TransformEdge, + Corner, + Edge, } from "types" import circle from "./circle" import dot from "./dot" @@ -60,10 +60,11 @@ export interface ShapeUtility { shape: K, bounds: Bounds, info: { - type: TransformEdge | TransformCorner | "center" + type: Edge | Corner | "center" initialShape: K scaleX: number scaleY: number + transformOrigin: number[] } ): K @@ -72,10 +73,11 @@ export interface ShapeUtility { shape: K, bounds: Bounds, info: { - type: TransformEdge | TransformCorner | "center" + type: Edge | Corner | "center" initialShape: K scaleX: number scaleY: number + transformOrigin: number[] } ): K diff --git a/lib/shape-utils/rectangle.tsx b/lib/shape-utils/rectangle.tsx index 58b35cec7..6d26c016a 100644 --- a/lib/shape-utils/rectangle.tsx +++ b/lib/shape-utils/rectangle.tsx @@ -1,16 +1,13 @@ import { v4 as uuid } from "uuid" import * as vec from "utils/vec" -import { - RectangleShape, - ShapeType, - TransformCorner, - TransformEdge, -} from "types" +import { RectangleShape, ShapeType, Corner, Edge } from "types" import { registerShapeUtils } from "./index" import { boundsCollidePolygon, boundsContainPolygon } from "utils/bounds" import { getBoundsFromPoints, getRotatedCorners, + getRotatedSize, + lerp, rotateBounds, translateBounds, } from "utils/utils" @@ -99,27 +96,33 @@ const rectangle = registerShapeUtils({ return shape }, - transform(shape, bounds, { initialShape, scaleX, scaleY }) { + transform(shape, bounds, { initialShape, transformOrigin, scaleX, scaleY }) { if (shape.rotation === 0) { shape.size = [bounds.width, bounds.height] shape.point = [bounds.minX, bounds.minY] } else { - // Center shape in resized bounds + // Size shape.size = vec.mul( initialShape.size, Math.min(Math.abs(scaleX), Math.abs(scaleY)) ) - shape.point = vec.sub( - vec.med([bounds.minX, bounds.minY], [bounds.maxX, bounds.maxY]), - vec.div(shape.size, 2) - ) - } + // Point + 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]), + ] - // Set rotation for flipped shapes - shape.rotation = initialShape.rotation - if (scaleX < 0) shape.rotation *= -1 - if (scaleY < 0) shape.rotation *= -1 + // Rotation + shape.rotation = + (scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0) + ? -initialShape.rotation + : initialShape.rotation + } return shape }, diff --git a/package.json b/package.json index 51143f3c1..5fbf73db7 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@stitches/react": "^0.1.9", "@types/uuid": "^8.3.0", "framer-motion": "^4.1.16", + "ismobilejs": "^1.1.1", "next": "10.2.0", "perfect-freehand": "^0.4.7", "prettier": "^2.3.0", diff --git a/state/commands/create-shape.ts b/state/commands/create-shape.ts index 38b820947..00c2aa6d3 100644 --- a/state/commands/create-shape.ts +++ b/state/commands/create-shape.ts @@ -1,6 +1,7 @@ import Command from "./command" import history from "../history" import { Data, Shape } from "types" +import { getPage } from "utils/utils" export default function registerShapeUtilsCommand(data: Data, shape: Shape) { const { currentPageId } = data @@ -11,17 +12,17 @@ export default function registerShapeUtilsCommand(data: Data, shape: Shape) { name: "translate_shapes", category: "canvas", do(data) { - const { shapes } = data.document.pages[currentPageId] + const page = getPage(data) - shapes[shape.id] = shape + page.shapes[shape.id] = shape data.selectedIds.clear() data.pointedId = undefined data.hoveredId = undefined }, undo(data) { - const { shapes } = data.document.pages[currentPageId] + const page = getPage(data) - delete shapes[shape.id] + delete page.shapes[shape.id] data.selectedIds.clear() data.pointedId = undefined diff --git a/state/commands/direct.ts b/state/commands/direct.ts index f51d35921..bb6cd4c3e 100644 --- a/state/commands/direct.ts +++ b/state/commands/direct.ts @@ -2,6 +2,7 @@ import Command from "./command" import history from "../history" import { DirectionSnapshot } from "state/sessions/direction-session" import { Data, LineShape, RayShape } from "types" +import { getPage } from "utils/utils" export default function directCommand( data: Data, @@ -14,7 +15,7 @@ export default function directCommand( name: "set_direction", category: "canvas", do(data) { - const { shapes } = data.document.pages[after.currentPageId] + const { shapes } = getPage(data) for (let { id, direction } of after.shapes) { const shape = shapes[id] as RayShape | LineShape @@ -23,7 +24,7 @@ export default function directCommand( } }, undo(data) { - const { shapes } = data.document.pages[before.currentPageId] + const { shapes } = getPage(data, before.currentPageId) for (let { id, direction } of after.shapes) { const shape = shapes[id] as RayShape | LineShape diff --git a/state/commands/generate.ts b/state/commands/generate.ts index c73848410..850a45456 100644 --- a/state/commands/generate.ts +++ b/state/commands/generate.ts @@ -2,6 +2,7 @@ import Command from "./command" import history from "../history" import { CodeControl, Data, Shape } from "types" import { current } from "immer" +import { getPage } from "utils/utils" export default function generateCommand( data: Data, @@ -9,12 +10,13 @@ export default function generateCommand( generatedShapes: Shape[] ) { const cData = current(data) + const page = getPage(cData) - const prevGeneratedShapes = Object.values( - cData.document.pages[currentPageId].shapes - ).filter((shape) => shape.isGenerated) + const currentShapes = page.shapes - const currentShapes = data.document.pages[currentPageId].shapes + const prevGeneratedShapes = Object.values(currentShapes).filter( + (shape) => shape.isGenerated + ) // Remove previous generated shapes for (let id in currentShapes) { @@ -34,7 +36,7 @@ export default function generateCommand( name: "translate_shapes", category: "canvas", do(data) { - const { shapes } = data.document.pages[currentPageId] + const { shapes } = getPage(data) data.selectedIds.clear() @@ -51,7 +53,7 @@ export default function generateCommand( } }, undo(data) { - const { shapes } = data.document.pages[currentPageId] + const { shapes } = getPage(data) // Remove generated shapes for (let id in shapes) { diff --git a/state/commands/rotate.ts b/state/commands/rotate.ts index 3d3cc074c..dd10be7a4 100644 --- a/state/commands/rotate.ts +++ b/state/commands/rotate.ts @@ -2,6 +2,7 @@ import Command from "./command" import history from "../history" import { Data } from "types" import { RotateSnapshot } from "state/sessions/rotate-session" +import { getPage } from "utils/utils" export default function rotateCommand( data: Data, @@ -14,7 +15,7 @@ export default function rotateCommand( name: "translate_shapes", category: "canvas", do(data) { - const { shapes } = data.document.pages[after.currentPageId] + const { shapes } = getPage(data) for (let { id, point, rotation } of after.shapes) { const shape = shapes[id] @@ -25,7 +26,7 @@ export default function rotateCommand( data.boundsRotation = after.boundsRotation }, undo(data) { - const { shapes } = data.document.pages[before.currentPageId] + const { shapes } = getPage(data, before.currentPageId) for (let { id, point, rotation } of before.shapes) { const shape = shapes[id] diff --git a/state/commands/transform-single.ts b/state/commands/transform-single.ts index 84f9033c8..43ae56868 100644 --- a/state/commands/transform-single.ts +++ b/state/commands/transform-single.ts @@ -1,9 +1,10 @@ import Command from "./command" import history from "../history" -import { Data, TransformCorner, TransformEdge } from "types" +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" export default function transformSingleCommand( data: Data, @@ -13,8 +14,7 @@ export default function transformSingleCommand( scaleY: number, isCreating: boolean ) { - const shape = - current(data).document.pages[after.currentPageId].shapes[after.id] + const shape = getPage(data, after.currentPageId).shapes[after.id] history.execute( data, @@ -23,32 +23,36 @@ export default function transformSingleCommand( category: "canvas", manualSelection: true, do(data) { - const { id, currentPageId, type, initialShape, initialShapeBounds } = - after + const { id, type, initialShape, initialShapeBounds } = after + + const { shapes } = getPage(data, after.currentPageId) data.selectedIds.clear() data.selectedIds.add(id) if (isCreating) { - data.document.pages[currentPageId].shapes[id] = shape + shapes[id] = shape } else { getShapeUtils(shape).transformSingle(shape, initialShapeBounds, { type, initialShape, scaleX, scaleY, + transformOrigin: [0.5, 0.5], }) } }, undo(data) { - const { id, currentPageId, type, initialShapeBounds } = before + const { id, type, initialShapeBounds } = before + + const { shapes } = getPage(data, before.currentPageId) data.selectedIds.clear() if (isCreating) { - delete data.document.pages[currentPageId].shapes[id] + delete shapes[id] } else { - const shape = data.document.pages[currentPageId].shapes[id] + const shape = shapes[id] data.selectedIds.add(id) getShapeUtils(shape).transform(shape, initialShapeBounds, { @@ -56,6 +60,7 @@ export default function transformSingleCommand( initialShape: after.initialShape, scaleX: 1, scaleY: 1, + transformOrigin: [0.5, 0.5], }) } }, diff --git a/state/commands/transform.ts b/state/commands/transform.ts index 5a0500a4a..b6acfc602 100644 --- a/state/commands/transform.ts +++ b/state/commands/transform.ts @@ -1,8 +1,9 @@ import Command from "./command" import history from "../history" -import { Data, TransformCorner, TransformEdge } from "types" +import { Data, Corner, Edge } from "types" import { TransformSnapshot } from "state/sessions/transform-session" import { getShapeUtils } from "lib/shape-utils" +import { getPage } from "utils/utils" export default function transformCommand( data: Data, @@ -17,32 +18,40 @@ export default function transformCommand( name: "translate_shapes", category: "canvas", do(data) { - const { type, currentPageId, selectedIds } = after + const { type, selectedIds } = after + + const { shapes } = getPage(data) selectedIds.forEach((id) => { - const { initialShape, initialShapeBounds } = after.shapeBounds[id] - const shape = data.document.pages[currentPageId].shapes[id] + const { initialShape, initialShapeBounds, transformOrigin } = + after.shapeBounds[id] + const shape = shapes[id] getShapeUtils(shape).transform(shape, initialShapeBounds, { type, initialShape, scaleX: 1, scaleY: 1, + transformOrigin, }) }) }, undo(data) { - const { type, currentPageId, selectedIds } = before + const { type, selectedIds } = before + + const { shapes } = getPage(data) selectedIds.forEach((id) => { - const { initialShape, initialShapeBounds } = before.shapeBounds[id] - const shape = data.document.pages[currentPageId].shapes[id] + const { initialShape, initialShapeBounds, transformOrigin } = + before.shapeBounds[id] + const shape = shapes[id] getShapeUtils(shape).transform(shape, initialShapeBounds, { type, initialShape, scaleX: 1, scaleY: 1, + transformOrigin, }) }) }, diff --git a/state/commands/translate.ts b/state/commands/translate.ts index 1e152f47c..7a05f3029 100644 --- a/state/commands/translate.ts +++ b/state/commands/translate.ts @@ -2,6 +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" export default function translateCommand( data: Data, @@ -18,8 +19,8 @@ export default function translateCommand( do(data, initial) { if (initial) return - const { shapes } = data.document.pages[after.currentPageId] - const { initialShapes } = after + const { initialShapes, currentPageId } = after + const { shapes } = getPage(data, currentPageId) const { clones } = before // ! data.selectedIds.clear() @@ -36,8 +37,8 @@ export default function translateCommand( } }, undo(data) { - const { shapes } = data.document.pages[before.currentPageId] - const { initialShapes, clones } = before + const { initialShapes, clones, currentPageId } = before + const { shapes } = getPage(data, currentPageId) data.selectedIds.clear() diff --git a/state/sessions/brush-session.ts b/state/sessions/brush-session.ts index 8ee5309ec..68613242b 100644 --- a/state/sessions/brush-session.ts +++ b/state/sessions/brush-session.ts @@ -1,15 +1,10 @@ import { current } from "immer" -import { ShapeUtil, Bounds, Data, Shapes } from "types" +import { Bounds, Data } from "types" import BaseSession from "./base-session" -import shapes, { getShapeUtils } from "lib/shape-utils" -import { getBoundsFromPoints } from "utils/utils" +import { getShapeUtils } from "lib/shape-utils" +import { getBoundsFromPoints, getShapes } from "utils/utils" import * as vec from "utils/vec" -interface BrushSnapshot { - selectedIds: Set - shapes: { id: string; test: (bounds: Bounds) => boolean }[] -} - export default class BrushSession extends BaseSession { origin: number[] snapshot: BrushSnapshot @@ -19,7 +14,7 @@ export default class BrushSession extends BaseSession { this.origin = vec.round(point) - this.snapshot = BrushSession.getSnapshot(data) + this.snapshot = getBrushSnapshot(data) } update = (data: Data, point: number[]) => { @@ -27,7 +22,8 @@ export default class BrushSession extends BaseSession { const brushBounds = getBoundsFromPoints([origin, point]) - for (let { test, id } of snapshot.shapes) { + for (let id in snapshot.shapeHitTests) { + const test = snapshot.shapeHitTests[id] if (test(brushBounds)) { data.selectedIds.add(id) } else if (data.selectedIds.has(id)) { @@ -46,30 +42,23 @@ export default class BrushSession extends BaseSession { complete = (data: Data) => { data.brush = undefined } +} - /** - * Get a snapshot of the current selected ids, for each shape that is - * not already selected, the shape's id and a test to see whether the - * brush will intersect that shape. For tests, start broad -> fine. - * @param data - * @returns - */ - static getSnapshot(data: Data): BrushSnapshot { - const { - selectedIds, - document: { pages }, - currentPageId, - } = current(data) - - return { - selectedIds: new Set(data.selectedIds), - shapes: Object.values(pages[currentPageId].shapes) - .filter((shape) => !selectedIds.has(shape.id)) - .map((shape) => ({ - id: shape.id, - test: (brushBounds: Bounds): boolean => - getShapeUtils(shape).hitTestBounds(shape, brushBounds), - })), - } +/** + * Get a snapshot of the current selected ids, for each shape that is + * not already selected, the shape's id and a test to see whether the + * brush will intersect that shape. For tests, start broad -> fine. + */ +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), + ]) + ), } } + +export type BrushSnapshot = ReturnType diff --git a/state/sessions/direction-session.ts b/state/sessions/direction-session.ts index 7911fb189..c13139f68 100644 --- a/state/sessions/direction-session.ts +++ b/state/sessions/direction-session.ts @@ -3,6 +3,7 @@ import * as vec from "utils/vec" import BaseSession from "./base-session" import commands from "state/commands" import { current } from "immer" +import { getPage } from "utils/utils" export default class DirectionSession extends BaseSession { delta = [0, 0] @@ -16,26 +17,22 @@ export default class DirectionSession extends BaseSession { } update(data: Data, point: number[]) { - const { currentPageId, shapes } = this.snapshot - const { document } = data + const { shapes } = this.snapshot + + const page = getPage(data) for (let { id } of shapes) { - const shape = document.pages[currentPageId].shapes[id] as - | RayShape - | LineShape + const shape = page.shapes[id] as RayShape | LineShape shape.direction = vec.uni(vec.vec(shape.point, point)) } } cancel(data: Data) { - const { document } = data + const page = getPage(data, this.snapshot.currentPageId) for (let { id, direction } of this.snapshot.shapes) { - const shape = document.pages[this.snapshot.currentPageId].shapes[id] as - | RayShape - | LineShape - + const shape = page.shapes[id] as RayShape | LineShape shape.direction = direction } } @@ -46,12 +43,7 @@ export default class DirectionSession extends BaseSession { } export function getDirectionSnapshot(data: Data) { - const { - document: { pages }, - currentPageId, - } = current(data) - - const { shapes } = pages[currentPageId] + const { shapes } = getPage(current(data)) let snapshapes: { id: string; direction: number[] }[] = [] @@ -63,7 +55,7 @@ export function getDirectionSnapshot(data: Data) { }) return { - currentPageId, + currentPageId: data.currentPageId, shapes: snapshapes, } } diff --git a/state/sessions/rotate-session.ts b/state/sessions/rotate-session.ts index fc661ed81..03f2dab85 100644 --- a/state/sessions/rotate-session.ts +++ b/state/sessions/rotate-session.ts @@ -3,8 +3,15 @@ import * as vec from "utils/vec" import BaseSession from "./base-session" import commands from "state/commands" import { current } from "immer" -import { getCommonBounds } from "utils/utils" -import { getShapeUtils } from "lib/shape-utils" +import { + getBoundsCenter, + getCommonBounds, + getPage, + getSelectedShapes, + getShapeBounds, +} from "utils/utils" + +const PI2 = Math.PI * 2 export default class RotateSession extends BaseSession { delta = [0, 0] @@ -17,33 +24,34 @@ export default class RotateSession extends BaseSession { this.snapshot = getRotateSnapshot(data) } - update(data: Data, point: number[]) { - const { currentPageId, boundsCenter, shapes } = this.snapshot - const { document } = data + update(data: Data, point: number[], isLocked: boolean) { + const { boundsCenter, shapes } = this.snapshot + const page = getPage(data) const a1 = vec.angle(boundsCenter, this.origin) const a2 = vec.angle(boundsCenter, point) - data.boundsRotation = - (this.snapshot.boundsRotation + (a2 - a1)) % (Math.PI * 2) + let rot = (PI2 + (a2 - a1)) % PI2 + + if (isLocked) { + rot = Math.floor((rot + Math.PI / 8) / (Math.PI / 4)) * (Math.PI / 4) + } + + data.boundsRotation = (PI2 + (this.snapshot.boundsRotation + rot)) % PI2 for (let { id, center, offset, rotation } of shapes) { - const shape = document.pages[currentPageId].shapes[id] - shape.rotation = rotation + ((a2 - a1) % (Math.PI * 2)) - const newCenter = vec.rotWith( - center, - boundsCenter, - (a2 - a1) % (Math.PI * 2) - ) + const shape = page.shapes[id] + shape.rotation = (PI2 + (rotation + rot)) % PI2 + const newCenter = vec.rotWith(center, boundsCenter, rot % PI2) shape.point = vec.sub(newCenter, offset) } } cancel(data: Data) { - const { document } = data + const page = getPage(data, this.snapshot.currentPageId) for (let { id, point, rotation } of this.snapshot.shapes) { - const shape = document.pages[this.snapshot.currentPageId].shapes[id] + const shape = page.shapes[id] shape.rotation = rotation shape.point = point } @@ -55,38 +63,26 @@ export default class RotateSession extends BaseSession { } export function getRotateSnapshot(data: Data) { - const { - boundsRotation, - selectedIds, - currentPageId, - document: { pages }, - } = current(data) - - const shapes = Array.from(selectedIds.values()).map( - (id) => pages[currentPageId].shapes[id] - ) + const shapes = getSelectedShapes(current(data)) // A mapping of selected shapes and their bounds const shapesBounds = Object.fromEntries( - shapes.map((shape) => [shape.id, getShapeUtils(shape).getBounds(shape)]) + shapes.map((shape) => [shape.id, getShapeBounds(shape)]) ) // The common (exterior) bounds of the selected shapes const bounds = getCommonBounds(...Object.values(shapesBounds)) - const boundsCenter = [ - bounds.minX + bounds.width / 2, - bounds.minY + bounds.height / 2, - ] + const boundsCenter = getBoundsCenter(bounds) return { - currentPageId, boundsCenter, - boundsRotation, + currentPageId: data.currentPageId, + boundsRotation: data.boundsRotation, shapes: shapes.map(({ id, point, rotation }) => { const bounds = shapesBounds[id] const offset = [bounds.width / 2, bounds.height / 2] - const center = vec.add(offset, [bounds.minX, bounds.minY]) + const center = getBoundsCenter(bounds) return { id, diff --git a/state/sessions/transform-session.ts b/state/sessions/transform-session.ts index a27c6ceca..39c997a35 100644 --- a/state/sessions/transform-session.ts +++ b/state/sessions/transform-session.ts @@ -1,25 +1,29 @@ -import { Data, TransformEdge, TransformCorner } from "types" +import { Data, Edge, Corner } from "types" import * as vec from "utils/vec" import BaseSession from "./base-session" import commands from "state/commands" import { current } from "immer" import { getShapeUtils } from "lib/shape-utils" import { + getBoundsCenter, + getBoundsFromPoints, getCommonBounds, + getPage, getRelativeTransformedBoundingBox, + getShapes, getTransformedBoundingBox, } from "utils/utils" export default class TransformSession extends BaseSession { scaleX = 1 scaleY = 1 - transformType: TransformEdge | TransformCorner + transformType: Edge | Corner | "center" origin: number[] snapshot: TransformSnapshot constructor( data: Data, - transformType: TransformCorner | TransformEdge, + transformType: Corner | Edge | "center", point: number[] ) { super(data) @@ -31,8 +35,9 @@ export default class TransformSession extends BaseSession { update(data: Data, point: number[], isAspectRatioLocked = false) { const { transformType } = this - const { currentPageId, selectedIds, shapeBounds, initialBounds } = - this.snapshot + const { selectedIds, shapeBounds, initialBounds } = this.snapshot + + const { shapes } = getPage(data) const newBoundingBox = getTransformedBoundingBox( initialBounds, @@ -48,7 +53,8 @@ export default class TransformSession extends BaseSession { // Now work backward to calculate a new bounding box for each of the shapes. selectedIds.forEach((id) => { - const { initialShape, initialShapeBounds } = shapeBounds[id] + const { initialShape, initialShapeBounds, transformOrigin } = + shapeBounds[id] const newShapeBounds = getRelativeTransformedBoundingBox( newBoundingBox, @@ -58,13 +64,27 @@ export default class TransformSession extends BaseSession { this.scaleY < 0 ) - const shape = data.document.pages[currentPageId].shapes[id] + const shape = shapes[id] + + // const transformOrigins = { + // [Edge.Top]: [0.5, 1], + // [Edge.Right]: [0, 0.5], + // [Edge.Bottom]: [0.5, 0], + // [Edge.Left]: [1, 0.5], + // [Corner.TopLeft]: [1, 1], + // [Corner.TopRight]: [0, 1], + // [Corner.BottomLeft]: [1, 0], + // [Corner.BottomRight]: [0, 0], + // } + + // const origin = transformOrigins[this.transformType] getShapeUtils(shape).transform(shape, newShapeBounds, { type: this.transformType, initialShape, scaleX: this.scaleX, scaleY: this.scaleY, + transformOrigin, }) }) } @@ -72,16 +92,20 @@ export default class TransformSession extends BaseSession { cancel(data: Data) { const { currentPageId, selectedIds, shapeBounds } = this.snapshot - selectedIds.forEach((id) => { - const shape = data.document.pages[currentPageId].shapes[id] + const page = getPage(data, currentPageId) - const { initialShape, initialShapeBounds } = shapeBounds[id] + selectedIds.forEach((id) => { + const shape = page.shapes[id] + + const { initialShape, initialShapeBounds, transformOrigin } = + shapeBounds[id] getShapeUtils(shape).transform(shape, initialShapeBounds, { type: this.transformType, initialShape, scaleX: 1, scaleY: 1, + transformOrigin, }) }) } @@ -99,7 +123,7 @@ export default class TransformSession extends BaseSession { export function getTransformSnapshot( data: Data, - transformType: TransformEdge | TransformCorner + transformType: Edge | Corner | "center" ) { const { document: { pages }, @@ -117,8 +141,12 @@ export function getTransformSnapshot( }) ) + const boundsArr = Object.values(shapesBounds) + // The common (exterior) bounds of the selected shapes - const bounds = getCommonBounds(...Object.values(shapesBounds)) + const bounds = getCommonBounds(...boundsArr) + + const initialInnerBounds = getBoundsFromPoints(boundsArr.map(getBoundsCenter)) // Return a mapping of shapes to bounds together with the relative // positions of the shape's bounds within the common bounds shape. @@ -129,11 +157,18 @@ export function getTransformSnapshot( initialBounds: bounds, shapeBounds: Object.fromEntries( Array.from(selectedIds.values()).map((id) => { + const initialShapeBounds = shapesBounds[id] + const ic = getBoundsCenter(initialShapeBounds) + + let ix = (ic[0] - initialInnerBounds.minX) / initialInnerBounds.width + let iy = (ic[1] - initialInnerBounds.minY) / initialInnerBounds.height + return [ id, { initialShape: pageShapes[id], - initialShapeBounds: shapesBounds[id], + initialShapeBounds, + transformOrigin: [ix, iy], }, ] }) diff --git a/state/sessions/transform-single-session.ts b/state/sessions/transform-single-session.ts index 945470b7a..dd4e0ffda 100644 --- a/state/sessions/transform-single-session.ts +++ b/state/sessions/transform-single-session.ts @@ -1,4 +1,4 @@ -import { Data, TransformEdge, TransformCorner } from "types" +import { Data, Edge, Corner } from "types" import * as vec from "utils/vec" import BaseSession from "./base-session" import commands from "state/commands" @@ -9,10 +9,13 @@ import { getCommonBounds, getRotatedCorners, getTransformAnchor, + getPage, + getShape, + getSelectedShapes, } from "utils/utils" export default class TransformSingleSession extends BaseSession { - transformType: TransformEdge | TransformCorner + transformType: Edge | Corner origin: number[] scaleX = 1 scaleY = 1 @@ -21,7 +24,7 @@ export default class TransformSingleSession extends BaseSession { constructor( data: Data, - transformType: TransformCorner | TransformEdge, + transformType: Corner | Edge, point: number[], isCreating = false ) { @@ -38,7 +41,7 @@ export default class TransformSingleSession extends BaseSession { const { initialShapeBounds, currentPageId, initialShape, id } = this.snapshot - const shape = data.document.pages[currentPageId].shapes[id] + const shape = getShape(data, id, currentPageId) const newBoundingBox = getTransformedBoundingBox( initialShapeBounds, @@ -56,6 +59,7 @@ export default class TransformSingleSession extends BaseSession { type: this.transformType, scaleX: this.scaleX, scaleY: this.scaleY, + transformOrigin: [0.5, 0.5], }) } @@ -63,15 +67,14 @@ export default class TransformSingleSession extends BaseSession { const { id, initialShape, initialShapeBounds, currentPageId } = this.snapshot - const { shapes } = data.document.pages[currentPageId] - - const shape = shapes[id] + const shape = getShape(data, id, currentPageId) getShapeUtils(shape).transform(shape, initialShapeBounds, { initialShape, type: this.transformType, scaleX: this.scaleX, scaleY: this.scaleY, + transformOrigin: [0.5, 0.5], }) } @@ -89,21 +92,14 @@ export default class TransformSingleSession extends BaseSession { export function getTransformSingleSnapshot( data: Data, - transformType: TransformEdge | TransformCorner + transformType: Edge | Corner ) { - const { - document: { pages }, - selectedIds, - currentPageId, - } = current(data) - - const id = Array.from(selectedIds)[0] - const shape = pages[currentPageId].shapes[id] + const shape = getSelectedShapes(current(data))[0] const bounds = getShapeUtils(shape).getBounds(shape) return { - id, - currentPageId, + id: shape.id, + currentPageId: data.currentPageId, type: transformType, initialShape: shape, initialShapeBounds: bounds, diff --git a/state/sessions/translate-session.ts b/state/sessions/translate-session.ts index 3f5a904b5..a52893217 100644 --- a/state/sessions/translate-session.ts +++ b/state/sessions/translate-session.ts @@ -4,6 +4,7 @@ import BaseSession from "./base-session" import commands from "state/commands" import { current } from "immer" import { v4 as uuid } from "uuid" +import { getPage, getSelectedShapes } from "utils/utils" export default class TranslateSession extends BaseSession { delta = [0, 0] @@ -19,7 +20,7 @@ export default class TranslateSession extends BaseSession { update(data: Data, point: number[], isAligned: boolean, isCloning: boolean) { const { currentPageId, clones, initialShapes } = this.snapshot - const { shapes } = data.document.pages[currentPageId] + const { shapes } = getPage(data, currentPageId) const delta = vec.vec(this.origin, point) @@ -71,7 +72,7 @@ export default class TranslateSession extends BaseSession { cancel(data: Data) { const { initialShapes, clones, currentPageId } = this.snapshot - const { shapes } = data.document.pages[currentPageId] + const { shapes } = getPage(data, currentPageId) for (const { id, point } of initialShapes) { shapes[id].point = point @@ -93,14 +94,10 @@ export default class TranslateSession extends BaseSession { } export function getTranslateSnapshot(data: Data) { - const { document, selectedIds, currentPageId } = current(data) - - const shapes = Array.from(selectedIds.values()).map( - (id) => document.pages[currentPageId].shapes[id] - ) + const shapes = getSelectedShapes(current(data)) return { - currentPageId, + currentPageId: data.currentPageId, initialShapes: shapes.map(({ id, point }) => ({ id, point })), clones: shapes.map((shape) => ({ ...shape, id: uuid() })), } diff --git a/state/state.ts b/state/state.ts index e71735548..08c48280e 100644 --- a/state/state.ts +++ b/state/state.ts @@ -1,13 +1,19 @@ import { createSelectorHook, createState } from "@state-designer/react" -import { clamp, getCommonBounds, screenToWorld } from "utils/utils" +import { + clamp, + getCommonBounds, + getPage, + getShape, + screenToWorld, +} from "utils/utils" import * as vec from "utils/vec" import { Data, PointerInfo, Shape, ShapeType, - TransformCorner, - TransformEdge, + Corner, + Edge, CodeControl, } from "types" import inputs from "./inputs" @@ -99,9 +105,11 @@ const state = createState({ SELECTED_ALL: "selectAll", POINTED_CANVAS: { to: "brushSelecting" }, POINTED_BOUNDS: { to: "pointingBounds" }, - POINTED_BOUNDS_EDGE: { to: "transformingSelection" }, - POINTED_BOUNDS_CORNER: { to: "transformingSelection" }, - POINTED_ROTATE_HANDLE: { to: "rotatingSelection" }, + POINTED_BOUNDS_HANDLE: { + if: "isPointingRotationHandle", + to: "rotatingSelection", + else: { to: "transformingSelection" }, + }, MOVED_OVER_SHAPE: { if: "pointHitsShape", then: { @@ -156,9 +164,12 @@ const state = createState({ }, rotatingSelection: { onEnter: "startRotateSession", + onExit: "clearBoundsRotation", on: { MOVED_POINTER: "updateRotateSession", PANNED_CAMERA: "updateRotateSession", + PRESSED_SHIFT_KEY: "keyUpdateRotateSession", + RELEASED_SHIFT_KEY: "keyUpdateRotateSession", STOPPED_POINTING: { do: "completeSession", to: "selecting" }, CANCELLED: { do: "cancelSession", to: "selecting" }, }, @@ -420,14 +431,19 @@ const state = createState({ return data.hoveredId === payload.target }, pointHitsShape(data, payload: { target: string; point: number[] }) { - const shape = - data.document.pages[data.currentPageId].shapes[payload.target] + const shape = getShape(data, payload.target) return getShapeUtils(shape).hitTest( shape, screenToWorld(payload.point, data) ) }, + isPointingRotationHandle( + data, + payload: { target: Edge | Corner | "rotate" } + ) { + return payload.target === "rotate" + }, }, actions: { /* --------------------- Shapes --------------------- */ @@ -438,7 +454,7 @@ const state = createState({ point: screenToWorld(payload.point, data), }) - data.document.pages[data.currentPageId].shapes[shape.id] = shape + getPage(data).shapes[shape.id] = shape data.selectedIds.clear() data.selectedIds.add(shape.id) }, @@ -449,7 +465,7 @@ const state = createState({ point: screenToWorld(payload.point, data), }) - data.document.pages[data.currentPageId].shapes[shape.id] = shape + getPage(data).shapes[shape.id] = shape data.selectedIds.clear() data.selectedIds.add(shape.id) }, @@ -461,7 +477,7 @@ const state = createState({ direction: [0, 1], }) - data.document.pages[data.currentPageId].shapes[shape.id] = shape + getPage(data).shapes[shape.id] = shape data.selectedIds.clear() data.selectedIds.add(shape.id) }, @@ -472,7 +488,7 @@ const state = createState({ radius: 1, }) - data.document.pages[data.currentPageId].shapes[shape.id] = shape + getPage(data).shapes[shape.id] = shape data.selectedIds.clear() data.selectedIds.add(shape.id) }, @@ -484,7 +500,7 @@ const state = createState({ radiusY: 1, }) - data.document.pages[data.currentPageId].shapes[shape.id] = shape + getPage(data).shapes[shape.id] = shape data.selectedIds.clear() data.selectedIds.add(shape.id) }, @@ -495,7 +511,7 @@ const state = createState({ size: [1, 1], }) - data.document.pages[data.currentPageId].shapes[shape.id] = shape + getPage(data).shapes[shape.id] = shape data.selectedIds.clear() data.selectedIds.add(shape.id) }, @@ -529,8 +545,15 @@ const state = createState({ screenToWorld(payload.point, data) ) }, + keyUpdateRotateSession(data, payload: PointerInfo) { + session.update( + data, + screenToWorld(inputs.pointer.point, data), + payload.shiftKey + ) + }, updateRotateSession(data, payload: PointerInfo) { - session.update(data, screenToWorld(payload.point, data)) + session.update(data, screenToWorld(payload.point, data), payload.shiftKey) }, // Dragging / Translating @@ -564,7 +587,7 @@ const state = createState({ // Dragging / Translating startTransformSession( data, - payload: PointerInfo & { target: TransformCorner | TransformEdge } + payload: PointerInfo & { target: Corner | Edge } ) { session = data.selectedIds.size === 1 @@ -583,7 +606,7 @@ const state = createState({ startDrawTransformSession(data, payload: PointerInfo) { session = new Sessions.TransformSingleSession( data, - TransformCorner.BottomRight, + Corner.BottomRight, screenToWorld(payload.point, data), true ) @@ -619,9 +642,10 @@ const state = createState({ /* -------------------- Selection ------------------- */ selectAll(data) { - const { selectedIds, document, currentPageId } = data + const { selectedIds } = data + const page = getPage(data) selectedIds.clear() - for (let id in document.pages[currentPageId].shapes) { + for (let id in page.shapes) { selectedIds.add(id) } }, @@ -654,6 +678,14 @@ const state = createState({ document.documentElement.style.setProperty("--camera-zoom", "1") }, + centerCamera(data) { + const { shapes } = getPage(data) + getCommonBounds() + data.camera.zoom = 1 + data.camera.point = [window.innerWidth / 2, window.innerHeight / 2] + + document.documentElement.style.setProperty("--camera-zoom", "1") + }, zoomCamera(data, payload: { delta: number; point: number[] }) { const { camera } = data const p0 = screenToWorld(payload.point, data) @@ -678,18 +710,16 @@ const state = createState({ ) }, deleteSelectedIds(data) { - const { document, currentPageId } = data - const { shapes } = document.pages[currentPageId] + const page = getPage(data) data.hoveredId = undefined data.pointedId = undefined data.selectedIds.forEach((id) => { - delete shapes[id] + delete page.shapes[id] // TODO: recursively delete children }) - data.document.pages[currentPageId].shapes = shapes data.selectedIds.clear() }, @@ -784,14 +814,12 @@ const state = createState({ return new Set(data.selectedIds) }, selectedBounds(data) { - const { - selectedIds, - currentPageId, - document: { pages }, - } = data + const { selectedIds } = data + + const page = getPage(data) const shapes = Array.from(selectedIds.values()) - .map((id) => pages[currentPageId].shapes[id]) + .map((id) => page.shapes[id]) .filter(Boolean) if (selectedIds.size === 0) return null diff --git a/types.ts b/types.ts index 73c83f09c..d58ae6ae5 100644 --- a/types.ts +++ b/types.ts @@ -146,14 +146,14 @@ export interface PointerInfo { altKey: boolean } -export enum TransformEdge { +export enum Edge { Top = "top_edge", Right = "right_edge", Bottom = "bottom_edge", Left = "left_edge", } -export enum TransformCorner { +export enum Corner { TopLeft = "top_left_corner", TopRight = "top_right_corner", BottomRight = "bottom_right_corner", diff --git a/utils/utils.ts b/utils/utils.ts index 98dabb288..e2500db2b 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -1,9 +1,9 @@ import Vector from "lib/code/vector" -import { getShapeUtils } from "lib/shape-utils" import React from "react" -import { Data, Bounds, TransformEdge, TransformCorner, Shape } from "types" -import * as svg from "./svg" +import { Data, Bounds, Edge, Corner, Shape } from "types" import * as vec from "./vec" +import _isMobile from "ismobilejs" +import { getShapeUtils } from "lib/shape-utils" export function screenToWorld(point: number[], data: Data) { return vec.sub(vec.div(point, data.camera.zoom), data.camera.point) @@ -42,7 +42,7 @@ export function getCommonBounds(...b: Bounds[]) { return bounds } -// export function getBoundsFromPoints(a: number[], b: number[]) { +// export function getBoundsFromTwoPoints(a: number[], b: number[]) { // const minX = Math.min(a[0], b[0]) // const maxX = Math.max(a[0], b[0]) // const minY = Math.min(a[1], b[1]) @@ -900,59 +900,59 @@ export function metaKey(e: KeyboardEvent | React.KeyboardEvent) { } export function getTransformAnchor( - type: TransformEdge | TransformCorner, + type: Edge | Corner, isFlippedX: boolean, isFlippedY: boolean ) { - let anchor: TransformCorner | TransformEdge = type + let anchor: Corner | Edge = type // Change corner anchors if flipped switch (type) { - case TransformCorner.TopLeft: { + case Corner.TopLeft: { if (isFlippedX && isFlippedY) { - anchor = TransformCorner.BottomRight + anchor = Corner.BottomRight } else if (isFlippedX) { - anchor = TransformCorner.TopRight + anchor = Corner.TopRight } else if (isFlippedY) { - anchor = TransformCorner.BottomLeft + anchor = Corner.BottomLeft } else { - anchor = TransformCorner.BottomRight + anchor = Corner.BottomRight } break } - case TransformCorner.TopRight: { + case Corner.TopRight: { if (isFlippedX && isFlippedY) { - anchor = TransformCorner.BottomLeft + anchor = Corner.BottomLeft } else if (isFlippedX) { - anchor = TransformCorner.TopLeft + anchor = Corner.TopLeft } else if (isFlippedY) { - anchor = TransformCorner.BottomRight + anchor = Corner.BottomRight } else { - anchor = TransformCorner.BottomLeft + anchor = Corner.BottomLeft } break } - case TransformCorner.BottomRight: { + case Corner.BottomRight: { if (isFlippedX && isFlippedY) { - anchor = TransformCorner.TopLeft + anchor = Corner.TopLeft } else if (isFlippedX) { - anchor = TransformCorner.BottomLeft + anchor = Corner.BottomLeft } else if (isFlippedY) { - anchor = TransformCorner.TopRight + anchor = Corner.TopRight } else { - anchor = TransformCorner.TopLeft + anchor = Corner.TopLeft } break } - case TransformCorner.BottomLeft: { + case Corner.BottomLeft: { if (isFlippedX && isFlippedY) { - anchor = TransformCorner.TopRight + anchor = Corner.TopRight } else if (isFlippedX) { - anchor = TransformCorner.BottomRight + anchor = Corner.BottomRight } else if (isFlippedY) { - anchor = TransformCorner.TopLeft + anchor = Corner.TopLeft } else { - anchor = TransformCorner.TopRight + anchor = Corner.TopRight } break } @@ -1030,6 +1030,18 @@ export function rotateBounds( } } +export function getRotatedSize(size: number[], rotation: number) { + const center = vec.div(size, 2) + + const points = [[0, 0], [size[0], 0], size, [0, size[1]]].map((point) => + vec.rotWith(point, center, rotation) + ) + + const bounds = getBoundsFromPoints(points) + + return [bounds.width, bounds.height] +} + export function getRotatedCorners(b: Bounds, rotation: number) { const center = [b.minX + b.width / 2, b.minY + b.height / 2] @@ -1043,7 +1055,7 @@ export function getRotatedCorners(b: Bounds, rotation: number) { export function getTransformedBoundingBox( bounds: Bounds, - handle: TransformCorner | TransformEdge | "center", + handle: Corner | Edge | "center", delta: number[], rotation = 0, isAspectRatioLocked = false @@ -1082,30 +1094,30 @@ export function getTransformedBoundingBox( corners should change. */ switch (handle) { - case TransformEdge.Top: - case TransformCorner.TopLeft: - case TransformCorner.TopRight: { + case Edge.Top: + case Corner.TopLeft: + case Corner.TopRight: { by0 += dy break } - case TransformEdge.Bottom: - case TransformCorner.BottomLeft: - case TransformCorner.BottomRight: { + case Edge.Bottom: + case Corner.BottomLeft: + case Corner.BottomRight: { by1 += dy break } } switch (handle) { - case TransformEdge.Left: - case TransformCorner.TopLeft: - case TransformCorner.BottomLeft: { + case Edge.Left: + case Corner.TopLeft: + case Corner.BottomLeft: { bx0 += dx break } - case TransformEdge.Right: - case TransformCorner.TopRight: - case TransformCorner.BottomRight: { + case Edge.Right: + case Corner.TopRight: + case Corner.BottomRight: { bx1 += dx break } @@ -1117,6 +1129,9 @@ export function getTransformedBoundingBox( const scaleX = (bx1 - bx0) / aw const scaleY = (by1 - by0) / ah + const flipX = scaleX < 0 + const flipY = scaleY < 0 + const bw = Math.abs(bx1 - bx0) const bh = Math.abs(by1 - by0) @@ -1134,36 +1149,36 @@ export function getTransformedBoundingBox( const th = bh * (scaleX < 0 ? 1 : -1) * ar switch (handle) { - case TransformCorner.TopLeft: { + case Corner.TopLeft: { if (isTall) by0 = by1 + tw else bx0 = bx1 + th break } - case TransformCorner.TopRight: { + case Corner.TopRight: { if (isTall) by0 = by1 + tw else bx1 = bx0 - th break } - case TransformCorner.BottomRight: { + case Corner.BottomRight: { if (isTall) by1 = by0 - tw else bx1 = bx0 - th break } - case TransformCorner.BottomLeft: { + case Corner.BottomLeft: { if (isTall) by1 = by0 - tw else bx0 = bx1 + th break } - case TransformEdge.Bottom: - case TransformEdge.Top: { + case Edge.Bottom: + case Edge.Top: { const m = (bx0 + bx1) / 2 const w = bh * ar bx0 = m - w / 2 bx1 = m + w / 2 break } - case TransformEdge.Left: - case TransformEdge.Right: { + case Edge.Left: + case Edge.Right: { const m = (by0 + by1) / 2 const h = bw / ar by0 = m - h / 2 @@ -1189,56 +1204,56 @@ export function getTransformedBoundingBox( const c1 = vec.med([bx0, by0], [bx1, by1]) switch (handle) { - case TransformCorner.TopLeft: { + case Corner.TopLeft: { cv = vec.sub( vec.rotWith([bx1, by1], c1, rotation), vec.rotWith([ax1, ay1], c0, rotation) ) break } - case TransformCorner.TopRight: { + case Corner.TopRight: { cv = vec.sub( vec.rotWith([bx0, by1], c1, rotation), vec.rotWith([ax0, ay1], c0, rotation) ) break } - case TransformCorner.BottomRight: { + case Corner.BottomRight: { cv = vec.sub( vec.rotWith([bx0, by0], c1, rotation), vec.rotWith([ax0, ay0], c0, rotation) ) break } - case TransformCorner.BottomLeft: { + case Corner.BottomLeft: { cv = vec.sub( vec.rotWith([bx1, by0], c1, rotation), vec.rotWith([ax1, ay0], c0, rotation) ) break } - case TransformEdge.Top: { + case Edge.Top: { cv = vec.sub( vec.rotWith(vec.med([bx0, by1], [bx1, by1]), c1, rotation), vec.rotWith(vec.med([ax0, ay1], [ax1, ay1]), c0, rotation) ) break } - case TransformEdge.Left: { + case Edge.Left: { cv = vec.sub( vec.rotWith(vec.med([bx1, by0], [bx1, by1]), c1, rotation), vec.rotWith(vec.med([ax1, ay0], [ax1, ay1]), c0, rotation) ) break } - case TransformEdge.Bottom: { + case Edge.Bottom: { cv = vec.sub( vec.rotWith(vec.med([bx0, by0], [bx1, by0]), c1, rotation), vec.rotWith(vec.med([ax0, ay0], [ax1, ay0]), c0, rotation) ) break } - case TransformEdge.Right: { + case Edge.Right: { cv = vec.sub( vec.rotWith(vec.med([bx0, by0], [bx0, by1]), c1, rotation), vec.rotWith(vec.med([ax0, ay0], [ax0, ay1]), c0, rotation) @@ -1273,8 +1288,8 @@ export function getTransformedBoundingBox( maxY: by1, width: bx1 - bx0, height: by1 - by0, - scaleX, - scaleY, + scaleX: ((bx1 - bx0) / (ax1 - ax0)) * (flipX ? -1 : 1), + scaleY: ((by1 - by0) / (ay1 - ay0)) * (flipY ? -1 : 1), } } @@ -1285,25 +1300,23 @@ export function getRelativeTransformedBoundingBox( isFlippedX: boolean, isFlippedY: boolean ) { - const minX = - bounds.minX + - bounds.width * - ((isFlippedX - ? initialBounds.maxX - initialShapeBounds.maxX - : initialShapeBounds.minX - initialBounds.minX) / - initialBounds.width) + const nx = + (isFlippedX + ? initialBounds.maxX - initialShapeBounds.maxX + : initialShapeBounds.minX - initialBounds.minX) / initialBounds.width - const minY = - bounds.minY + - bounds.height * - ((isFlippedY - ? initialBounds.maxY - initialShapeBounds.maxY - : initialShapeBounds.minY - initialBounds.minY) / - initialBounds.height) + const ny = + (isFlippedY + ? initialBounds.maxY - initialShapeBounds.maxY + : initialShapeBounds.minY - initialBounds.minY) / initialBounds.height - const width = (initialShapeBounds.width / initialBounds.width) * bounds.width - const height = - (initialShapeBounds.height / initialBounds.height) * bounds.height + const nw = initialShapeBounds.width / initialBounds.width + const nh = initialShapeBounds.height / initialBounds.height + + const minX = bounds.minX + bounds.width * nx + const minY = bounds.minY + bounds.height * ny + const width = bounds.width * nw + const height = bounds.height * nh return { minX, @@ -1314,3 +1327,42 @@ export function getRelativeTransformedBoundingBox( height, } } + +export function getShape( + data: Data, + shapeId: string, + pageId = data.currentPageId +) { + return data.document.pages[pageId].shapes[shapeId] +} + +export function getPage(data: Data, pageId = data.currentPageId) { + return data.document.pages[pageId] +} + +export function getCurrentCode(data: Data, fileId = data.currentCodeFileId) { + return data.document.code[fileId] +} + +export function getShapes(data: Data, pageId = data.currentPageId) { + const page = getPage(data, pageId) + return Object.values(page.shapes) +} + +export function getSelectedShapes(data: Data, pageId = data.currentPageId) { + const page = getPage(data, pageId) + const ids = Array.from(data.selectedIds.values()) + return ids.map((id) => page.shapes[id]) +} + +export function isMobile() { + return _isMobile() +} + +export function getShapeBounds(shape: Shape) { + return getShapeUtils(shape).getBounds(shape) +} + +export function getBoundsCenter(bounds: Bounds) { + return [bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2] +} diff --git a/yarn.lock b/yarn.lock index 245de2e7c..04b42f13a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4154,6 +4154,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +ismobilejs@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ismobilejs/-/ismobilejs-1.1.1.tgz#c56ca0ae8e52b24ca0f22ba5ef3215a2ddbbaa0e" + integrity sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw== + isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"