This commit is contained in:
Steve Ruiz 2021-05-28 15:43:37 +01:00
commit 79c254a938
16 changed files with 337 additions and 133 deletions

6
.prettierrc Normal file
View file

@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}

View file

@ -1,16 +1,17 @@
import * as React from "react"
import { Edge, Corner } from "types"
import { useSelector } from "state"
import { getSelectedShapes, isMobile } from "utils/utils"
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"
import CenterHandle from './center-handle'
import CornerHandle from './corner-handle'
import EdgeHandle from './edge-handle'
import RotateHandle from './rotate-handle'
import Selected from '../selected'
export default function Bounds() {
const isBrushing = useSelector((s) => s.isIn("brushSelecting"))
const isSelecting = useSelector((s) => s.isIn("selecting"))
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 }) =>
@ -20,17 +21,18 @@ export default function Bounds() {
if (!bounds) return null
if (!isSelecting) return null
const size = (isMobile().any ? 16 : 8) / zoom // Touch target size
const size = (isMobile().any ? 12 : 8) / zoom // Touch target size
return (
<g
pointerEvents={isBrushing ? "none" : "all"}
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})`}
>
<Selected bounds={bounds} />
<CenterHandle bounds={bounds} />
<EdgeHandle size={size} bounds={bounds} edge={Edge.Top} />
<EdgeHandle size={size} bounds={bounds} edge={Edge.Right} />

View file

@ -1,13 +1,14 @@
import styled from "styles"
import React, { useCallback, useRef } from "react"
import useZoomEvents from "hooks/useZoomEvents"
import useCamera from "hooks/useCamera"
import Page from "./page"
import Brush from "./brush"
import state from "state"
import Bounds from "./bounds/bounding-box"
import BoundsBg from "./bounds/bounds-bg"
import inputs from "state/inputs"
import styled from 'styles'
import state from 'state'
import inputs from 'state/inputs'
import React, { useCallback, useRef } from 'react'
import useZoomEvents from 'hooks/useZoomEvents'
import useCamera from 'hooks/useCamera'
import Defs from './defs'
import Page from './page'
import Brush from './brush'
import Bounds from './bounds/bounding-box'
import BoundsBg from './bounds/bounds-bg'
export default function Canvas() {
const rCanvas = useRef<SVGSVGElement>(null)
@ -18,16 +19,16 @@ export default function Canvas() {
const handlePointerDown = useCallback((e: React.PointerEvent) => {
rCanvas.current.setPointerCapture(e.pointerId)
state.send("POINTED_CANVAS", inputs.pointerDown(e, "canvas"))
state.send('POINTED_CANVAS', inputs.pointerDown(e, 'canvas'))
}, [])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
state.send("MOVED_POINTER", inputs.pointerMove(e))
state.send('MOVED_POINTER', inputs.pointerMove(e))
}, [])
const handlePointerUp = useCallback((e: React.PointerEvent) => {
rCanvas.current.releasePointerCapture(e.pointerId)
state.send("STOPPED_POINTING", { id: "canvas", ...inputs.pointerUp(e) })
state.send('STOPPED_POINTING', { id: 'canvas', ...inputs.pointerUp(e) })
}, [])
return (
@ -38,6 +39,7 @@ export default function Canvas() {
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<Defs />
<MainGroup ref={rGroup}>
<BoundsBg />
<Page />
@ -48,18 +50,18 @@ export default function Canvas() {
)
}
const MainSVG = styled("svg", {
position: "fixed",
const MainSVG = styled('svg', {
position: 'fixed',
top: 0,
left: 0,
width: "100%",
height: "100%",
touchAction: "none",
width: '100%',
height: '100%',
touchAction: 'none',
zIndex: 100,
"& *": {
userSelect: "none",
'& *': {
userSelect: 'none',
},
})
const MainGroup = styled("g", {})
const MainGroup = styled('g', {})

View file

@ -0,0 +1,25 @@
import { getShapeUtils } from "lib/shape-utils"
import { useSelector } from "state"
import { deepCompareArrays, getPage } from "utils/utils"
export default function Defs() {
const currentPageShapeIds = useSelector(({ data }) => {
return Object.values(getPage(data).shapes)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
}, deepCompareArrays)
return (
<defs>
{currentPageShapeIds.map((id) => (
<Def key={id} id={id} />
))}
</defs>
)
}
export function Def({ id }: { id: string }) {
const shape = useSelector(({ data }) => getPage(data).shapes[id])
return getShapeUtils(shape).render(shape)
}

View file

@ -1,6 +1,6 @@
import { useSelector } from "state"
import { deepCompareArrays, getPage } from "utils/utils"
import Shape from "./shape"
import { useSelector } from 'state'
import { deepCompareArrays, getPage } from 'utils/utils'
import Shape from './shape'
/*
On each state change, compare node ids of all shapes
@ -15,10 +15,12 @@ export default function Page() {
.map((shape) => shape.id)
}, deepCompareArrays)
const isSelecting = useSelector((s) => s.isIn('selecting'))
return (
<>
{currentPageShapeIds.map((shapeId) => (
<Shape key={shapeId} id={shapeId} />
<Shape key={shapeId} id={shapeId} isSelecting={isSelecting} />
))}
</>
)

View file

@ -0,0 +1,58 @@
import styled from 'styles'
import { useSelector } from 'state'
import {
deepCompareArrays,
getBoundsCenter,
getPage,
getSelectedShapes,
} from 'utils/utils'
import * as vec from 'utils/vec'
import { getShapeUtils } from 'lib/shape-utils'
import { Bounds } from 'types'
import useShapeEvents from 'hooks/useShapeEvents'
import { useRef } from 'react'
export default function Selected({ bounds }: { bounds: Bounds }) {
const currentPageShapeIds = useSelector(({ data }) => {
return Array.from(data.selectedIds.values())
}, deepCompareArrays)
return (
<g>
{currentPageShapeIds.map((id) => (
<ShapeOutline key={id} id={id} bounds={bounds} />
))}
</g>
)
}
export function ShapeOutline({ id, bounds }: { id: string; bounds: Bounds }) {
const rIndicator = useRef<SVGUseElement>(null)
const shape = useSelector(({ data }) => getPage(data).shapes[id])
const shapeBounds = getShapeUtils(shape).getBounds(shape)
const events = useShapeEvents(id, rIndicator)
return (
<Indicator
ref={rIndicator}
as="use"
href={'#' + id}
transform={`rotate(${shape.rotation * (180 / Math.PI)},${getBoundsCenter(
shapeBounds
)}) translate(${vec.sub(shape.point, [bounds.minX, bounds.minY])})`}
{...events}
/>
)
}
const Indicator = styled('path', {
zStrokeWidth: 1,
strokeLineCap: 'round',
strokeLinejoin: 'round',
stroke: '$selected',
fill: 'transparent',
pointerEvents: 'all',
})

View file

@ -1,24 +1,25 @@
import React, { useCallback, useRef, memo } from "react"
import state, { useSelector } from "state"
import inputs from "state/inputs"
import styled from "styles"
import { getShapeUtils } from "lib/shape-utils"
import { getPage } from "utils/utils"
function Shape({ id }: { id: string }) {
const rGroup = useRef<SVGGElement>(null)
import React, { useCallback, useRef, memo } from 'react'
import state, { useSelector } from 'state'
import inputs from 'state/inputs'
import styled from 'styles'
import { getShapeUtils } from 'lib/shape-utils'
import { getPage } from 'utils/utils'
import { ShapeStyles } from 'types'
function Shape({ id, isSelecting }: { id: string; isSelecting: boolean }) {
const isHovered = useSelector((state) => state.data.hoveredId === id)
const isSelected = useSelector((state) => state.values.selectedIds.has(id))
const shape = useSelector(({ data }) => getPage(data).shapes[id])
const rGroup = useRef<SVGGElement>(null)
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.stopPropagation()
rGroup.current.setPointerCapture(e.pointerId)
state.send("POINTED_SHAPE", inputs.pointerDown(e, id))
state.send('POINTED_SHAPE', inputs.pointerDown(e, id))
},
[id]
)
@ -27,27 +28,27 @@ function Shape({ id }: { id: string }) {
(e: React.PointerEvent) => {
e.stopPropagation()
rGroup.current.releasePointerCapture(e.pointerId)
state.send("STOPPED_POINTING", inputs.pointerUp(e))
state.send('STOPPED_POINTING', inputs.pointerUp(e))
},
[id]
)
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
state.send("HOVERED_SHAPE", inputs.pointerEnter(e, id))
state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id))
},
[id, shape]
)
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
state.send("MOVED_OVER_SHAPE", inputs.pointerEnter(e, id))
state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id))
},
[id, shape]
)
const handlePointerLeave = useCallback(
() => state.send("UNHOVERED_SHAPE", { target: id }),
() => state.send('UNHOVERED_SHAPE', { target: id }),
[id]
)
@ -71,47 +72,38 @@ function Shape({ id }: { id: string }) {
onPointerLeave={handlePointerLeave}
onPointerMove={handlePointerMove}
>
<defs>{getShapeUtils(shape).render(shape)}</defs>
<HoverIndicator as="use" xlinkHref={"#" + id} />
<MainShape as="use" xlinkHref={"#" + id} {...shape.style} />
<Indicator as="use" xlinkHref={"#" + id} />
{isSelecting && <HoverIndicator as="use" href={'#' + id} />}
<StyledShape id={id} style={shape.style} />
</StyledGroup>
)
}
const MainShape = styled("use", {
const StyledShape = memo(
({ id, style }: { id: string; style: ShapeStyles }) => {
return <MainShape as="use" href={'#' + id} {...style} />
}
)
const MainShape = styled('use', {
zStrokeWidth: 1,
})
const Indicator = styled("path", {
fill: "none",
stroke: "transparent",
zStrokeWidth: 1,
pointerEvents: "none",
strokeLineCap: "round",
strokeLinejoin: "round",
const HoverIndicator = styled('path', {
fill: 'none',
stroke: 'transparent',
pointerEvents: 'all',
strokeLinecap: 'round',
strokeLinejoin: 'round',
transform: 'all .2s',
})
const HoverIndicator = styled("path", {
fill: "none",
stroke: "transparent",
pointerEvents: "all",
strokeLinecap: "round",
strokeLinejoin: "round",
transform: "all .2s",
})
const StyledGroup = styled("g", {
const StyledGroup = styled('g', {
[`& ${HoverIndicator}`]: {
opacity: "0",
opacity: '0',
},
variants: {
isSelected: {
true: {
[`& ${Indicator}`]: {
stroke: "$selected",
},
},
true: {},
false: {},
},
isHovered: {
@ -125,8 +117,8 @@ const StyledGroup = styled("g", {
isHovered: true,
css: {
[`& ${HoverIndicator}`]: {
opacity: "1",
stroke: "$hint",
opacity: '1',
stroke: '$hint',
zStrokeWidth: [8, 4],
},
},
@ -136,8 +128,8 @@ const StyledGroup = styled("g", {
isHovered: false,
css: {
[`& ${HoverIndicator}`]: {
opacity: "1",
stroke: "$hint",
opacity: '1',
stroke: '$hint',
zStrokeWidth: [6, 3],
},
},
@ -147,8 +139,8 @@ const StyledGroup = styled("g", {
isHovered: true,
css: {
[`& ${HoverIndicator}`]: {
opacity: "1",
stroke: "$hint",
opacity: '1',
stroke: '$hint',
zStrokeWidth: [8, 4],
},
},
@ -156,6 +148,6 @@ const StyledGroup = styled("g", {
],
})
export { Indicator, HoverIndicator }
export { HoverIndicator }
export default memo(Shape)

53
hooks/useShapeEvents.ts Normal file
View file

@ -0,0 +1,53 @@
import { MutableRefObject, useCallback } from 'react'
import state from 'state'
import inputs from 'state/inputs'
export default function useShapeEvents(
id: string,
rGroup: MutableRefObject<SVGElement>
) {
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.stopPropagation()
rGroup.current.setPointerCapture(e.pointerId)
state.send('POINTED_SHAPE', inputs.pointerDown(e, id))
},
[id]
)
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
e.stopPropagation()
rGroup.current.releasePointerCapture(e.pointerId)
state.send('STOPPED_POINTING', inputs.pointerUp(e))
},
[id]
)
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
state.send('HOVERED_SHAPE', inputs.pointerEnter(e, id))
},
[id]
)
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
state.send('MOVED_OVER_SHAPE', inputs.pointerEnter(e, id))
},
[id]
)
const handlePointerLeave = useCallback(
() => state.send('UNHOVERED_SHAPE', { target: id }),
[id]
)
return {
onPointerDown: handlePointerDown,
onPointerUp: handlePointerUp,
onPointerEnter: handlePointerEnter,
onPointerMove: handlePointerMove,
onPointerLeave: handlePointerLeave,
}
}

View file

@ -2,6 +2,7 @@ import React, { useEffect, useRef } from "react"
import state from "state"
import inputs from "state/inputs"
import * as vec from "utils/vec"
import { usePinch } from "react-use-gesture"
/**
* Capture zoom gestures (pinches, wheels and pans) and send to the state.
@ -65,5 +66,36 @@ export default function useZoomEvents(
}
}, [ref])
return {}
const rPinchDa = useRef<number[] | undefined>(undefined)
const rPinchAngle = useRef<number>(undefined)
const rPinchPoint = useRef<number[] | undefined>(undefined)
const bind = usePinch(({ pinching, da, origin }) => {
if (!pinching) {
state.send("STOPPED_PINCHING")
rPinchDa.current = undefined
rPinchPoint.current = undefined
return
}
if (rPinchPoint.current === undefined) {
state.send("STARTED_PINCHING")
rPinchDa.current = da
rPinchPoint.current = origin
}
const [distanceDelta, angleDelta] = vec.sub(rPinchDa.current, da)
state.send("PINCHED", {
delta: vec.sub(rPinchPoint.current, origin),
point: origin,
distanceDelta,
angleDelta,
})
rPinchDa.current = da
rPinchPoint.current = origin
})
return { ...bind() }
}

View file

@ -1,19 +1,19 @@
import { v4 as uuid } from "uuid"
import * as vec from "utils/vec"
import { DrawShape, ShapeType } from "types"
import { registerShapeUtils } from "./index"
import { intersectPolylineBounds } from "utils/intersections"
import { boundsContainPolygon } from "utils/bounds"
import getStroke from "perfect-freehand"
import { v4 as uuid } from 'uuid'
import * as vec from 'utils/vec'
import { DrawShape, ShapeType } from 'types'
import { registerShapeUtils } from './index'
import { intersectPolylineBounds } from 'utils/intersections'
import { boundsContainPolygon } from 'utils/bounds'
import getStroke from 'perfect-freehand'
import {
getBoundsFromPoints,
getSvgPathFromStroke,
translateBounds,
} from "utils/utils"
import { DotCircle } from "components/canvas/misc"
import { shades } from "lib/colors"
} from 'utils/utils'
import { DotCircle } from 'components/canvas/misc'
import { shades } from 'lib/colors'
const pathCache = new WeakMap<DrawShape, string>([])
const pathCache = new WeakMap<number[][], string>([])
const draw = registerShapeUtils<DrawShape>({
boundsCache: new WeakMap([]),
@ -23,8 +23,8 @@ const draw = registerShapeUtils<DrawShape>({
id: uuid(),
type: ShapeType.Draw,
isGenerated: false,
name: "Draw",
parentId: "page0",
name: 'Draw',
parentId: 'page0',
childIndex: 0,
point: [0, 0],
points: [[0, 0]],
@ -32,10 +32,10 @@ const draw = registerShapeUtils<DrawShape>({
...props,
style: {
strokeWidth: 2,
strokeLinecap: "round",
strokeLinejoin: "round",
strokeLinecap: 'round',
strokeLinejoin: 'round',
...props.style,
stroke: "transparent",
stroke: 'transparent',
},
}
},
@ -44,19 +44,18 @@ const draw = registerShapeUtils<DrawShape>({
const { id, point, points } = shape
if (points.length < 2) {
return <DotCircle cx={point[0]} cy={point[1]} r={3} />
return <DotCircle id={id} cx={point[0]} cy={point[1]} r={3} />
}
if (!pathCache.has(shape)) {
pathCache.set(shape, getSvgPathFromStroke(getStroke(points)))
if (!pathCache.has(points)) {
pathCache.set(points, getSvgPathFromStroke(getStroke(points)))
}
return <path id={id} d={pathCache.get(shape)} />
return <path id={id} d={pathCache.get(points)} />
},
applyStyles(shape, style) {
Object.assign(shape.style, style)
shape.style.fill = "transparent"
return this
},

View file

@ -21,6 +21,7 @@
"react": "17.0.2",
"react-dom": "17.0.2",
"react-feather": "^2.0.9",
"react-use-gesture": "^9.1.3",
"uuid": "^8.3.2"
},
"devDependencies": {

View file

@ -1,10 +1,10 @@
import { current } from "immer"
import { Data, DrawShape } from "types"
import BaseSession from "./base-session"
import { getShapeUtils } from "lib/shape-utils"
import { getPage, simplify } from "utils/utils"
import * as vec from "utils/vec"
import commands from "state/commands"
import { current } from 'immer'
import { Data, DrawShape } from 'types'
import BaseSession from './base-session'
import { getShapeUtils } from 'lib/shape-utils'
import { getPage, simplify } from 'utils/utils'
import * as vec from 'utils/vec'
import commands from 'state/commands'
export default class BrushSession extends BaseSession {
origin: number[]
@ -29,7 +29,7 @@ export default class BrushSession extends BaseSession {
update = (data: Data, point: number[]) => {
const { shapeId } = this
const lp = vec.med(this.previous, point)
const lp = vec.med(this.previous, vec.toPrecision(point))
this.points.push(vec.sub(lp, this.origin))
this.previous = lp
@ -46,15 +46,7 @@ export default class BrushSession extends BaseSession {
}
complete = (data: Data) => {
commands.draw(
data,
this.shapeId,
this.snapshot.points,
simplify(this.points, 0.1 / data.camera.zoom).map(([x, y]) => [
Math.trunc(x * 100) / 100,
Math.trunc(y * 100) / 100,
])
)
commands.draw(data, this.shapeId, this.snapshot.points, this.points)
}
}

View file

@ -130,6 +130,7 @@ const state = createState({
STRETCHED: "stretchSelection",
DISTRIBUTED: "distributeSelection",
MOVED: "moveSelection",
STARTED_PINCHING: { to: "pinching" },
},
initial: "notPointing",
states: {
@ -248,6 +249,12 @@ const state = createState({
},
},
},
pinching: {
on: {
STOPPED_PINCHING: { to: "selecting" },
PINCHED: { do: "pinchCamera" },
},
},
draw: {
initial: "creating",
states: {
@ -831,12 +838,31 @@ const state = createState({
setZoomCSS(camera.zoom)
},
panCamera(data, payload: { delta: number[]; point: number[] }) {
panCamera(data, payload: { delta: number[] }) {
const { camera } = data
data.camera.point = vec.sub(
camera.point,
vec.div(payload.delta, camera.zoom)
)
camera.point = vec.sub(camera.point, vec.div(payload.delta, camera.zoom))
},
pinchCamera(
data,
payload: {
delta: number[]
distanceDelta: number
angleDelta: number
point: number[]
}
) {
const { camera } = data
camera.point = vec.sub(camera.point, vec.div(payload.delta, camera.zoom))
const next = camera.zoom - (payload.distanceDelta / 300) * camera.zoom
const p0 = screenToWorld(payload.point, data)
camera.zoom = clamp(next, 0.1, 3)
const p1 = screenToWorld(payload.point, data)
camera.point = vec.add(camera.point, vec.sub(p1, p0))
setZoomCSS(camera.zoom)
},
deleteSelectedIds(data) {
commands.deleteSelected(data)

View file

@ -45,7 +45,7 @@ const { styled, global, css, theme, getCssString } = createCss({
zStrokeWidth: () => (value: number | number[]) => {
if (Array.isArray(value)) {
return {
strokeWidth: `calc(${value[0]} / var(--camera-zoom))`,
strokeWidth: `calc(${value[0]}px / var(--camera-zoom))`,
}
}
@ -61,7 +61,7 @@ const { styled, global, css, theme, getCssString } = createCss({
// }
return {
strokeWidth: `calc(${value} / var(--camera-zoom))`,
strokeWidth: `calc(${value}px / var(--camera-zoom))`,
}
},
},

View file

@ -6,7 +6,7 @@
export function clamp(n: number, min: number): number
export function clamp(n: number, min: number, max: number): number
export function clamp(n: number, min: number, max?: number): number {
return Math.max(min, typeof max !== "undefined" ? Math.min(n, max) : n)
return Math.max(min, typeof max !== 'undefined' ? Math.min(n, max) : n)
}
/**
@ -477,3 +477,12 @@ export function distanceToLineSegment(
export function nudge(A: number[], B: number[], d: number) {
return add(A, mul(uni(vec(A, B)), d))
}
/**
* Round a vector to a precision length.
* @param a
* @param n
*/
export function toPrecision(a: number[], n = 3) {
return [+a[0].toPrecision(n), +a[1].toPrecision(n)]
}

View file

@ -6697,6 +6697,11 @@ react-style-singleton@^2.1.0:
invariant "^2.2.4"
tslib "^1.0.0"
react-use-gesture@^9.1.3:
version "9.1.3"
resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-9.1.3.tgz#92bd143e4f58e69bd424514a5bfccba2a1d62ec0"
integrity sha512-CdqA2SmS/fj3kkS2W8ZU8wjTbVBAIwDWaRprX7OKaj7HlGwBasGEFggmk5qNklknqk9zK/h8D355bEJFTpqEMg==
react@17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"