Document and shapes

This commit is contained in:
Steve Ruiz 2021-05-09 22:22:25 +01:00
parent e7a52dd70f
commit f38481efee
18 changed files with 292 additions and 54 deletions

View file

@ -1,21 +1,20 @@
import styled from "styles" import styled from "styles"
import { useRef } from "react" import { useRef } from "react"
import useZoomEvents from "hooks/useZoomEvents" import useZoomEvents from "hooks/useZoomEvents"
import useZoomPanEffect from "hooks/useZoomPanEffect" import useCamera from "hooks/useCamera"
import Page from "./page"
export default function Canvas() { export default function Canvas() {
const rCanvas = useRef<SVGSVGElement>(null) const rCanvas = useRef<SVGSVGElement>(null)
const rGroup = useRef<SVGGElement>(null) const rGroup = useRef<SVGGElement>(null)
const events = useZoomEvents(rCanvas) const events = useZoomEvents(rCanvas)
useZoomPanEffect(rGroup) useCamera(rGroup)
return ( return (
<MainSVG ref={rCanvas} {...events}> <MainSVG ref={rCanvas} {...events}>
<MainGroup ref={rGroup}> <MainGroup ref={rGroup}>
<circle cx={100} cy={100} r={50} /> <Page />
<circle cx={500} cy={500} r={200} />
<circle cx={200} cy={800} r={100} />
</MainGroup> </MainGroup>
</MainSVG> </MainSVG>
) )

View file

@ -0,0 +1,24 @@
import { useSelector } from "state"
import { deepCompareArrays } from "utils/utils"
import Shape from "./shape"
/*
On each state change, compare node ids of all shapes
on the current page. Kind of expensive but only happens
here; and still cheaper than any other pattern I've found.
*/
export default function Page() {
const currentPageShapeIds = useSelector((state) => {
const { currentPageId, document } = state.data
return Object.keys(document.pages[currentPageId].shapes)
}, deepCompareArrays)
return (
<>
{currentPageShapeIds.map((shapeId) => (
<Shape key={shapeId} id={shapeId} />
))}
</>
)
}

View file

@ -0,0 +1,29 @@
import { memo } from "react"
import { useSelector } from "state"
import { ShapeType } from "types"
import Circle from "./shapes/circle"
import Rectangle from "./shapes/rectangle"
/*
Gets the shape from the current page's shapes, using the
provided ID. Depending on the shape's type, return the
component for that type.
*/
function Shape({ id }: { id: string }) {
const shape = useSelector((state) => {
const { currentPageId, document } = state.data
return document.pages[currentPageId].shapes[id]
})
switch (shape.type) {
case ShapeType.Circle:
return <Circle {...shape} />
case ShapeType.Rectangle:
return <Rectangle {...shape} />
default:
return null
}
}
export default memo(Shape)

View file

@ -0,0 +1,5 @@
import { CircleShape } from "types"
export default function Circle({ point, radius }: CircleShape) {
return <circle cx={point[0]} cy={point[1]} r={radius} fill="black" />
}

View file

@ -0,0 +1,13 @@
import { RectangleShape } from "types"
export default function Rectangle({ point, size }: RectangleShape) {
return (
<rect
x={point[0]}
y={point[1]}
width={size[0]}
height={size[1]}
fill="black"
/>
)
}

View file

@ -35,6 +35,7 @@ const StatusBarContainer = styled("div", {
alignItems: "center", alignItems: "center",
backgroundColor: "white", backgroundColor: "white",
gap: 8, gap: 8,
fontSize: "$1",
padding: "0 16px", padding: "0 16px",
zIndex: 200, zIndex: 200,
}) })

View file

@ -6,9 +6,7 @@ import state from "state"
* the SVG group to reflect the correct zoom and pan. * the SVG group to reflect the correct zoom and pan.
* @param ref * @param ref
*/ */
export default function useZoomPanEffect( export default function useCamera(ref: React.MutableRefObject<SVGGElement>) {
ref: React.MutableRefObject<SVGGElement>
) {
useEffect(() => { useEffect(() => {
let { camera } = state.data let { camera } = state.data
@ -19,7 +17,6 @@ export default function useZoomPanEffect(
const { point, zoom } = data.camera const { point, zoom } = data.camera
if (point !== camera.point || zoom !== camera.zoom) { if (point !== camera.point || zoom !== camera.zoom) {
console.log("changed!")
g.setAttribute( g.setAttribute(
"transform", "transform",
`scale(${zoom}) translate(${point[0]} ${point[1]})` `scale(${zoom}) translate(${point[0]} ${point[1]})`

View file

@ -2,6 +2,11 @@ import React, { useEffect, useRef } from "react"
import state from "state" import state from "state"
import * as vec from "utils/vec" import * as vec from "utils/vec"
/**
* Capture zoom gestures (pinches, wheels and pans) and send to the state.
* @param ref
* @returns
*/
export default function useZoomEvents( export default function useZoomEvents(
ref: React.MutableRefObject<SVGSVGElement> ref: React.MutableRefObject<SVGSVGElement>
) { ) {
@ -30,17 +35,19 @@ export default function useZoomEvents(
} }
function handleTouchMove(e: TouchEvent) { function handleTouchMove(e: TouchEvent) {
if (e.ctrlKey) {
e.preventDefault() e.preventDefault()
}
if (e.touches.length === 2) { if (e.touches.length === 2) {
const { clientX: x0, clientY: y0 } = e.touches[0] const { clientX: x0, clientY: y0 } = e.touches[0]
const { clientX: x1, clientY: y1 } = e.touches[1] const { clientX: x1, clientY: y1 } = e.touches[1]
const dist = vec.dist([x0, y0], [x1, y1]) const dist = vec.dist([x0, y0], [x1, y1])
const point = vec.med([x0, y0], [x1, y1])
state.send("WHEELED", { delta: [0, dist - rTouchDist.current] }) state.send("WHEELED", {
delta: dist - rTouchDist.current,
point,
})
rTouchDist.current = dist rTouchDist.current = dist
} }

View file

@ -10,10 +10,12 @@
"dependencies": { "dependencies": {
"@state-designer/react": "^1.7.1", "@state-designer/react": "^1.7.1",
"@stitches/react": "^0.1.9", "@stitches/react": "^0.1.9",
"@types/uuid": "^8.3.0",
"next": "10.2.0", "next": "10.2.0",
"perfect-freehand": "^0.4.7", "perfect-freehand": "^0.4.7",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2" "react-dom": "17.0.2",
"uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/next": "^9.0.0", "@types/next": "^9.0.0",

View file

@ -1,21 +1,32 @@
import Document, { import NextDocument, { Html, Head, Main, NextScript } from "next/document"
DocumentContext, import { dark, getCssString } from "styles"
Html,
Head,
Main,
NextScript,
} from "next/document"
import { dark } from "styles"
class MyDocument extends Document { class MyDocument extends NextDocument {
static async getInitialProps(ctx: DocumentContext) { static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx) try {
return { ...initialProps } const initialProps = await NextDocument.getInitialProps(ctx)
return {
...initialProps,
styles: (
<>
{initialProps.styles}
<style
id="stitches"
dangerouslySetInnerHTML={{ __html: getCssString() }}
/>
</>
),
}
} catch (e) {
console.log(e.message)
} finally {
}
} }
render() { render() {
return ( return (
<Html> <Html lang="en">
<Head /> <Head />
<body className={dark}> <body className={dark}>
<Main /> <Main />

View file

@ -1,5 +1,3 @@
import Head from "next/head"
import Image from "next/image"
import Editor from "components/editor" import Editor from "components/editor"
export default function Home() { export default function Home() {

View file

@ -1,13 +1,56 @@
import { createSelectorHook, createState } from "@state-designer/react" import { createSelectorHook, createState } from "@state-designer/react"
import * as vec from "utils/vec"
import { clamp, screenToWorld } from "utils/utils" import { clamp, screenToWorld } from "utils/utils"
import { IData } from "types" import * as vec from "utils/vec"
import { Data, ShapeType } from "types"
const initialData: IData = { const initialData: Data = {
camera: { camera: {
point: [0, 0], point: [0, 0],
zoom: 1, zoom: 1,
}, },
currentPageId: "page0",
document: {
pages: {
page0: {
id: "page0",
type: "page",
name: "Page 0",
childIndex: 0,
shapes: {
shape0: {
id: "shape0",
type: ShapeType.Circle,
name: "Shape 0",
parentId: "page0",
childIndex: 1,
point: [100, 100],
radius: 50,
rotation: 0,
},
shape1: {
id: "shape1",
type: ShapeType.Rectangle,
name: "Shape 1",
parentId: "page0",
childIndex: 1,
point: [300, 300],
size: [200, 200],
rotation: 0,
},
shape2: {
id: "shape2",
type: ShapeType.Circle,
name: "Shape 2",
parentId: "page0",
childIndex: 2,
point: [200, 800],
radius: 25,
rotation: 0,
},
},
},
},
},
} }
const state = createState({ const state = createState({

View file

@ -1,10 +1 @@
* { @import url("https://fonts.googleapis.com/css2?family=Recursive:wght@500;700&display=swap");
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
overscroll-behavior: none;
}

View file

@ -1,4 +1,10 @@
import styled, { css, globalStyles, light, dark } from "./stitches.config" import styled, {
css,
getCssString,
globalStyles,
light,
dark,
} from "./stitches.config"
export default styled export default styled
export { css, globalStyles, light, dark } export { css, getCssString, globalStyles, light, dark }

View file

@ -1,11 +1,22 @@
import { createCss, global } from "@stitches/react" import { createCss, defaultThemeMap } from "@stitches/react"
const { styled, css, theme } = createCss({ const { styled, global, css, theme, getCssString } = createCss({
themeMap: {
...defaultThemeMap,
},
theme: { theme: {
colors: {}, colors: {},
space: {}, space: {},
fontSizes: {}, fontSizes: {
fonts: {}, 0: "10px",
1: "12px",
2: "13px",
3: "16px",
4: "18px",
},
fonts: {
ui: `"Recursive", system-ui, sans-serif`,
},
fontWeights: {}, fontWeights: {},
lineHeights: {}, lineHeights: {},
letterSpacings: {}, letterSpacings: {},
@ -26,12 +37,14 @@ const dark = theme({})
const globalStyles = global({ const globalStyles = global({
"*": { boxSizing: "border-box" }, "*": { boxSizing: "border-box" },
"html, body": { "html, body": {
padding: "0", padding: "0px",
margin: "0", margin: "0px",
overscrollBehavior: "none", overscrollBehavior: "none",
fontFamily: "$ui",
fontSize: "$2",
}, },
}) })
export default styled export default styled
export { css, globalStyles, light, dark } export { css, getCssString, globalStyles, light, dark }

View file

@ -1,6 +1,95 @@
export interface IData { export interface Data {
camera: { camera: {
point: number[] point: number[]
zoom: number zoom: number
} }
currentPageId: string
selectedIds: string[]
pointedId: string
document: {
pages: Record<string, Page>
}
} }
export interface Page {
id: string
type: "page"
childIndex: number
name: string
shapes: Record<string, Shape>
}
export enum ShapeType {
Circle = "circle",
Ellipse = "ellipse",
Square = "square",
Rectangle = "rectangle",
Line = "line",
LineSegment = "lineSegment",
Dot = "dot",
Ray = "ray",
Glob = "glob",
Spline = "spline",
Cubic = "cubic",
Conic = "conic",
}
export interface BaseShape {
id: string
type: ShapeType
parentId: string
childIndex: number
name: string
rotation: 0
}
export interface DotShape extends BaseShape {
type: ShapeType.Dot
point: number[]
}
export interface CircleShape extends BaseShape {
type: ShapeType.Circle
point: number[]
radius: number
}
export interface EllipseShape extends BaseShape {
type: ShapeType.Ellipse
point: number[]
radiusX: number
radiusY: number
}
export interface LineShape extends BaseShape {
type: ShapeType.Line
point: number[]
vector: number[]
}
export interface RayShape extends BaseShape {
type: ShapeType.Ray
point: number[]
vector: number[]
}
export interface LineSegmentShape extends BaseShape {
type: ShapeType.LineSegment
start: number[]
end: number[]
}
export interface RectangleShape extends BaseShape {
type: ShapeType.Rectangle
point: number[]
size: number[]
}
export type Shape =
| CircleShape
| EllipseShape
| DotShape
| LineShape
| RayShape
| LineSegmentShape
| RectangleShape

View file

@ -1,8 +1,8 @@
import { IData } from "types" import { Data } from "types"
import * as svg from "./svg" import * as svg from "./svg"
import * as vec from "./vec" import * as vec from "./vec"
export function screenToWorld(point: number[], data: IData) { export function screenToWorld(point: number[], data: Data) {
return vec.add(vec.div(point, data.camera.zoom), data.camera.point) return vec.add(vec.div(point, data.camera.zoom), data.camera.point)
} }

View file

@ -1440,6 +1440,11 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
"@types/uuid@^8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f"
integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==
"@types/yargs-parser@*": "@types/yargs-parser@*":
version "20.2.0" version "20.2.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9"
@ -7263,6 +7268,11 @@ uuid@^3.3.2:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
v8-compile-cache@^2.0.3: v8-compile-cache@^2.0.3:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"