refactors bounds, improves transforming rotating shapes

This commit is contained in:
Steve Ruiz 2021-05-22 16:45:24 +01:00
parent fb0bb47c19
commit b752782753
33 changed files with 690 additions and 632 deletions

View file

@ -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<SVGRectElement>(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 (
<StyledBoundsBg
ref={rBounds}
x={minX}
y={minY}
width={Math.max(1, width)}
height={Math.max(1, height)}
onPointerDown={(e) => {
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",
})

View file

@ -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 (
<g
pointerEvents={isBrushing ? "none" : "all"}
transform={`rotate(${rotation * (180 / Math.PI)},${minX + width / 2}, ${
minY + height / 2
})`}
>
<StyledBounds
x={minX}
y={minY}
width={width}
height={height}
pointerEvents="none"
/>
<EdgeHorizontal
x={minX + p}
y={minY}
width={Math.max(0, width - p * 2)}
height={p}
edge={TransformEdge.Top}
/>
<EdgeVertical
x={maxX}
y={minY + p}
width={p}
height={Math.max(0, height - p * 2)}
edge={TransformEdge.Right}
/>
<EdgeHorizontal
x={minX + p}
y={maxY}
width={Math.max(0, width - p * 2)}
height={p}
edge={TransformEdge.Bottom}
/>
<EdgeVertical
x={minX}
y={minY + p}
width={p}
height={Math.max(0, height - p * 2)}
edge={TransformEdge.Left}
/>
<Corner
x={minX}
y={minY}
width={cp}
height={cp}
corner={TransformCorner.TopLeft}
/>
<Corner
x={maxX}
y={minY}
width={cp}
height={cp}
corner={TransformCorner.TopRight}
/>
<Corner
x={maxX}
y={maxY}
width={cp}
height={cp}
corner={TransformCorner.BottomRight}
/>
<Corner
x={minX}
y={maxY}
width={cp}
height={cp}
corner={TransformCorner.BottomLeft}
/>
<RotateHandle x={minX + width / 2} y={minY - cp * 2} r={cp / 2} />
</g>
)
}
function RotateHandle({ x, y, r }: { x: number; y: number; r: number }) {
const rRotateHandle = useRef<SVGCircleElement>(null)
return (
<StyledRotateHandle
ref={rRotateHandle}
cx={x}
cy={y}
r={r}
onPointerDown={(e) => {
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<SVGRectElement>(null)
return (
<g>
<StyledCorner
ref={rCorner}
x={x + width * -0.5}
y={y + height * -0.5}
width={width}
height={height}
corner={corner}
onPointerDown={(e) => {
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))
}}
/>
</g>
)
}
function EdgeHorizontal({
x,
y,
width,
height,
edge,
}: {
x: number
y: number
width: number
height: number
edge: TransformEdge.Top | TransformEdge.Bottom
}) {
const rEdge = useRef<SVGRectElement>(null)
return (
<StyledEdge
ref={rEdge}
x={x}
y={y - height / 2}
width={width}
height={height}
onPointerDown={(e) => {
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<SVGRectElement>(null)
return (
<StyledEdge
ref={rEdge}
x={x - width / 2}
y={y}
width={width}
height={height}
onPointerDown={(e) => {
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,
})

View file

@ -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 (
<g
pointerEvents={isBrushing ? "none" : "all"}
transform={`
rotate(${rotation * (180 / Math.PI)},
${(bounds.minX + bounds.maxX) / 2},
${(bounds.minY + bounds.maxY) / 2})
translate(${bounds.minX},${bounds.minY})`}
>
<CenterHandle bounds={bounds} />
<EdgeHandle size={size} bounds={bounds} edge={Edge.Top} />
<EdgeHandle size={size} bounds={bounds} edge={Edge.Right} />
<EdgeHandle size={size} bounds={bounds} edge={Edge.Bottom} />
<EdgeHandle size={size} bounds={bounds} edge={Edge.Left} />
<CornerHandle size={size} bounds={bounds} corner={Corner.TopLeft} />
<CornerHandle size={size} bounds={bounds} corner={Corner.TopRight} />
<CornerHandle size={size} bounds={bounds} corner={Corner.BottomRight} />
<CornerHandle size={size} bounds={bounds} corner={Corner.BottomLeft} />
<RotateHandle size={size} bounds={bounds} />
</g>
)
}

View file

@ -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<SVGRectElement>) {
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<SVGRectElement>) {
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<SVGRectElement>(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 (
<StyledBoundsBg
ref={rBounds}
width={Math.max(1, width)}
height={Math.max(1, height)}
transform={`
rotate(${rotation * (180 / Math.PI)},
${(bounds.minX + bounds.maxX) / 2},
${(bounds.minY + bounds.maxY) / 2})
translate(${bounds.minX},${bounds.minY})`}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
/>
)
}
const StyledBoundsBg = styled("rect", {
fill: "$boundsBg",
})

View file

@ -0,0 +1,18 @@
import styled from "styles"
import { Bounds } from "types"
export default function CenterHandle({ bounds }: { bounds: Bounds }) {
return (
<StyledBounds
width={bounds.width}
height={bounds.height}
pointerEvents="none"
/>
)
}
const StyledBounds = styled("rect", {
fill: "none",
stroke: "$bounds",
zStrokeWidth: 2,
})

View file

@ -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 (
<StyledCorner
corner={corner}
x={(isLeft ? 0 : bounds.width) - size / 2}
y={(isTop ? 0 : bounds.height) - size / 2}
width={size}
height={size}
{...events}
/>
)
}
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" },
},
},
})

View file

@ -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 (
<StyledEdge
edge={edge}
x={isHorizontal ? size / 2 : (isFarEdge ? bounds.width : 0) - size / 2}
y={isHorizontal ? (isFarEdge ? bounds.height : 0) - size / 2 : size / 2}
width={isHorizontal ? Math.max(0, bounds.width - size) : size}
height={isHorizontal ? size : Math.max(0, bounds.height - size)}
{...events}
/>
)
}
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" },
},
},
})

View file

@ -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 (
<StyledRotateHandle
cursor="grab"
cx={bounds.width / 2}
cy={size * -2}
r={size / 2}
{...events}
/>
)
}
const StyledRotateHandle = styled("circle", {
stroke: "$bounds",
fill: "#fff",
zStrokeWidth: 2,
cursor: "grab",
})

View file

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

View file

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

View file

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

View file

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

View file

@ -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<CircleShape>({
// 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<CircleShape>({
]
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<CircleShape>({
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<CircleShape>({
]
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<CircleShape>({
]
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<CircleShape>({
]
break
}
case TransformEdge.Left: {
case Edge.Left: {
shape.radius = bounds.width / 2
shape.point = [
bounds.minX,

View file

@ -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<K extends Shape> {
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<K extends Shape> {
shape: K,
bounds: Bounds,
info: {
type: TransformEdge | TransformCorner | "center"
type: Edge | Corner | "center"
initialShape: K
scaleX: number
scaleY: number
transformOrigin: number[]
}
): K

View file

@ -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<RectangleShape>({
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
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string>
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<typeof getBrushSnapshot>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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