diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..e1076edfa --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false +} diff --git a/components/canvas/bounds/bounding-box.tsx b/components/canvas/bounds/bounding-box.tsx index 21fd40dd8..e8ab0293e 100644 --- a/components/canvas/bounds/bounding-box.tsx +++ b/components/canvas/bounds/bounding-box.tsx @@ -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 ( + diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 9db337c07..fd4240927 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -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(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} > + @@ -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', {}) diff --git a/components/canvas/defs.tsx b/components/canvas/defs.tsx new file mode 100644 index 000000000..35df99467 --- /dev/null +++ b/components/canvas/defs.tsx @@ -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 ( + + {currentPageShapeIds.map((id) => ( + + ))} + + ) +} + +export function Def({ id }: { id: string }) { + const shape = useSelector(({ data }) => getPage(data).shapes[id]) + + return getShapeUtils(shape).render(shape) +} diff --git a/components/canvas/page.tsx b/components/canvas/page.tsx index 19e179e48..c0cf1f2e8 100644 --- a/components/canvas/page.tsx +++ b/components/canvas/page.tsx @@ -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) => ( - + ))} ) diff --git a/components/canvas/selected.tsx b/components/canvas/selected.tsx new file mode 100644 index 000000000..4d5570c79 --- /dev/null +++ b/components/canvas/selected.tsx @@ -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 ( + + {currentPageShapeIds.map((id) => ( + + ))} + + ) +} + +export function ShapeOutline({ id, bounds }: { id: string; bounds: Bounds }) { + const rIndicator = useRef(null) + + const shape = useSelector(({ data }) => getPage(data).shapes[id]) + + const shapeBounds = getShapeUtils(shape).getBounds(shape) + + const events = useShapeEvents(id, rIndicator) + + return ( + + ) +} + +const Indicator = styled('path', { + zStrokeWidth: 1, + strokeLineCap: 'round', + strokeLinejoin: 'round', + stroke: '$selected', + fill: 'transparent', + pointerEvents: 'all', +}) diff --git a/components/canvas/shape.tsx b/components/canvas/shape.tsx index 401cad289..4d553c9f9 100644 --- a/components/canvas/shape.tsx +++ b/components/canvas/shape.tsx @@ -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(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(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} > - {getShapeUtils(shape).render(shape)} - - - + {isSelecting && } + ) } -const MainShape = styled("use", { +const StyledShape = memo( + ({ id, style }: { id: string; style: ShapeStyles }) => { + return + } +) + +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) diff --git a/hooks/useShapeEvents.ts b/hooks/useShapeEvents.ts new file mode 100644 index 000000000..baf604c14 --- /dev/null +++ b/hooks/useShapeEvents.ts @@ -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 +) { + 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, + } +} diff --git a/hooks/useZoomEvents.ts b/hooks/useZoomEvents.ts index 6b90f9038..f93871f51 100644 --- a/hooks/useZoomEvents.ts +++ b/hooks/useZoomEvents.ts @@ -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(undefined) + const rPinchAngle = useRef(undefined) + const rPinchPoint = useRef(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() } } diff --git a/lib/shape-utils/draw.tsx b/lib/shape-utils/draw.tsx index 78a2d846a..60f74a3d7 100644 --- a/lib/shape-utils/draw.tsx +++ b/lib/shape-utils/draw.tsx @@ -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([]) +const pathCache = new WeakMap([]) const draw = registerShapeUtils({ boundsCache: new WeakMap([]), @@ -23,8 +23,8 @@ const draw = registerShapeUtils({ 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({ ...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({ const { id, point, points } = shape if (points.length < 2) { - return + return } - if (!pathCache.has(shape)) { - pathCache.set(shape, getSvgPathFromStroke(getStroke(points))) + if (!pathCache.has(points)) { + pathCache.set(points, getSvgPathFromStroke(getStroke(points))) } - return + return }, applyStyles(shape, style) { Object.assign(shape.style, style) - shape.style.fill = "transparent" return this }, diff --git a/package.json b/package.json index ffd557431..c5c31558d 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/state/sessions/draw-session.ts b/state/sessions/draw-session.ts index bddd4d091..9fbd2634c 100644 --- a/state/sessions/draw-session.ts +++ b/state/sessions/draw-session.ts @@ -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) } } diff --git a/state/state.ts b/state/state.ts index 11a0998dd..ec88ee2f0 100644 --- a/state/state.ts +++ b/state/state.ts @@ -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) diff --git a/styles/stitches.config.ts b/styles/stitches.config.ts index d7f495e20..f8db4a406 100644 --- a/styles/stitches.config.ts +++ b/styles/stitches.config.ts @@ -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))`, } }, }, diff --git a/utils/vec.ts b/utils/vec.ts index 8997bca3f..8c32fd15a 100644 --- a/utils/vec.ts +++ b/utils/vec.ts @@ -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)] +} diff --git a/yarn.lock b/yarn.lock index d26890712..82b9cfc62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"