Document and shapes
This commit is contained in:
parent
e7a52dd70f
commit
f38481efee
18 changed files with 292 additions and 54 deletions
|
@ -1,21 +1,20 @@
|
|||
import styled from "styles"
|
||||
import { useRef } from "react"
|
||||
import useZoomEvents from "hooks/useZoomEvents"
|
||||
import useZoomPanEffect from "hooks/useZoomPanEffect"
|
||||
import useCamera from "hooks/useCamera"
|
||||
import Page from "./page"
|
||||
|
||||
export default function Canvas() {
|
||||
const rCanvas = useRef<SVGSVGElement>(null)
|
||||
const rGroup = useRef<SVGGElement>(null)
|
||||
const events = useZoomEvents(rCanvas)
|
||||
|
||||
useZoomPanEffect(rGroup)
|
||||
useCamera(rGroup)
|
||||
|
||||
return (
|
||||
<MainSVG ref={rCanvas} {...events}>
|
||||
<MainGroup ref={rGroup}>
|
||||
<circle cx={100} cy={100} r={50} />
|
||||
<circle cx={500} cy={500} r={200} />
|
||||
<circle cx={200} cy={800} r={100} />
|
||||
<Page />
|
||||
</MainGroup>
|
||||
</MainSVG>
|
||||
)
|
||||
|
|
24
components/canvas/page.tsx
Normal file
24
components/canvas/page.tsx
Normal 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} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
29
components/canvas/shape.tsx
Normal file
29
components/canvas/shape.tsx
Normal 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)
|
5
components/canvas/shapes/circle.tsx
Normal file
5
components/canvas/shapes/circle.tsx
Normal 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" />
|
||||
}
|
13
components/canvas/shapes/rectangle.tsx
Normal file
13
components/canvas/shapes/rectangle.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -35,6 +35,7 @@ const StatusBarContainer = styled("div", {
|
|||
alignItems: "center",
|
||||
backgroundColor: "white",
|
||||
gap: 8,
|
||||
fontSize: "$1",
|
||||
padding: "0 16px",
|
||||
zIndex: 200,
|
||||
})
|
||||
|
|
|
@ -6,9 +6,7 @@ import state from "state"
|
|||
* the SVG group to reflect the correct zoom and pan.
|
||||
* @param ref
|
||||
*/
|
||||
export default function useZoomPanEffect(
|
||||
ref: React.MutableRefObject<SVGGElement>
|
||||
) {
|
||||
export default function useCamera(ref: React.MutableRefObject<SVGGElement>) {
|
||||
useEffect(() => {
|
||||
let { camera } = state.data
|
||||
|
||||
|
@ -19,7 +17,6 @@ export default function useZoomPanEffect(
|
|||
const { point, zoom } = data.camera
|
||||
|
||||
if (point !== camera.point || zoom !== camera.zoom) {
|
||||
console.log("changed!")
|
||||
g.setAttribute(
|
||||
"transform",
|
||||
`scale(${zoom}) translate(${point[0]} ${point[1]})`
|
|
@ -2,6 +2,11 @@ import React, { useEffect, useRef } from "react"
|
|||
import state from "state"
|
||||
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(
|
||||
ref: React.MutableRefObject<SVGSVGElement>
|
||||
) {
|
||||
|
@ -30,17 +35,19 @@ export default function useZoomEvents(
|
|||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
}
|
||||
e.preventDefault()
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
const { clientX: x0, clientY: y0 } = e.touches[0]
|
||||
const { clientX: x1, clientY: y1 } = e.touches[1]
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -10,10 +10,12 @@
|
|||
"dependencies": {
|
||||
"@state-designer/react": "^1.7.1",
|
||||
"@stitches/react": "^0.1.9",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"next": "10.2.0",
|
||||
"perfect-freehand": "^0.4.7",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2"
|
||||
"react-dom": "17.0.2",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/next": "^9.0.0",
|
||||
|
|
|
@ -1,21 +1,32 @@
|
|||
import Document, {
|
||||
DocumentContext,
|
||||
Html,
|
||||
Head,
|
||||
Main,
|
||||
NextScript,
|
||||
} from "next/document"
|
||||
import { dark } from "styles"
|
||||
import NextDocument, { Html, Head, Main, NextScript } from "next/document"
|
||||
import { dark, getCssString } from "styles"
|
||||
|
||||
class MyDocument extends Document {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
const initialProps = await Document.getInitialProps(ctx)
|
||||
return { ...initialProps }
|
||||
class MyDocument extends NextDocument {
|
||||
static async getInitialProps(ctx) {
|
||||
try {
|
||||
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() {
|
||||
return (
|
||||
<Html>
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body className={dark}>
|
||||
<Main />
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import Head from "next/head"
|
||||
import Image from "next/image"
|
||||
import Editor from "components/editor"
|
||||
|
||||
export default function Home() {
|
||||
|
|
|
@ -1,13 +1,56 @@
|
|||
import { createSelectorHook, createState } from "@state-designer/react"
|
||||
import * as vec from "utils/vec"
|
||||
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: {
|
||||
point: [0, 0],
|
||||
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({
|
||||
|
|
|
@ -1,10 +1 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
@import url("https://fonts.googleapis.com/css2?family=Recursive:wght@500;700&display=swap");
|
||||
|
|
|
@ -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 { css, globalStyles, light, dark }
|
||||
export { css, getCssString, globalStyles, light, dark }
|
||||
|
|
|
@ -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: {
|
||||
colors: {},
|
||||
space: {},
|
||||
fontSizes: {},
|
||||
fonts: {},
|
||||
fontSizes: {
|
||||
0: "10px",
|
||||
1: "12px",
|
||||
2: "13px",
|
||||
3: "16px",
|
||||
4: "18px",
|
||||
},
|
||||
fonts: {
|
||||
ui: `"Recursive", system-ui, sans-serif`,
|
||||
},
|
||||
fontWeights: {},
|
||||
lineHeights: {},
|
||||
letterSpacings: {},
|
||||
|
@ -26,12 +37,14 @@ const dark = theme({})
|
|||
const globalStyles = global({
|
||||
"*": { boxSizing: "border-box" },
|
||||
"html, body": {
|
||||
padding: "0",
|
||||
margin: "0",
|
||||
padding: "0px",
|
||||
margin: "0px",
|
||||
overscrollBehavior: "none",
|
||||
fontFamily: "$ui",
|
||||
fontSize: "$2",
|
||||
},
|
||||
})
|
||||
|
||||
export default styled
|
||||
|
||||
export { css, globalStyles, light, dark }
|
||||
export { css, getCssString, globalStyles, light, dark }
|
||||
|
|
91
types.ts
91
types.ts
|
@ -1,6 +1,95 @@
|
|||
export interface IData {
|
||||
export interface Data {
|
||||
camera: {
|
||||
point: 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
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { IData } from "types"
|
||||
import { Data } from "types"
|
||||
import * as svg from "./svg"
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -1440,6 +1440,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
|
||||
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@*":
|
||||
version "20.2.0"
|
||||
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"
|
||||
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:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
|
||||
|
|
Loading…
Reference in a new issue