├── .DS_Store ├── .gitignore ├── .prettierrc ├── README.md ├── components ├── app.tsx ├── canvas-pixi │ ├── canvas.tsx │ └── surface.ts ├── hooks │ ├── useKeyboardEvents.tsx │ ├── useViewBox.tsx │ └── useWindowEvents.ts ├── overlays │ ├── button.tsx │ ├── model.tsx │ ├── overlays.tsx │ ├── positions.tsx │ ├── value.tsx │ └── zoom-indicator.tsx ├── state │ ├── box-selecter.tsx │ ├── box-transforms.tsx │ ├── database.ts │ ├── index.tsx │ └── pure.tsx ├── theme.tsx ├── toolbar │ ├── icon-button.tsx │ ├── icons │ │ ├── arrow.svg │ │ ├── bottom.svg │ │ ├── box.svg │ │ ├── center-x.svg │ │ ├── center-y.svg │ │ ├── config.js │ │ ├── delete.svg │ │ ├── distribute-x.svg │ │ ├── distribute-y.svg │ │ ├── flip-arrow.svg │ │ ├── index.html │ │ ├── invert-arrow.svg │ │ ├── left.svg │ │ ├── redo.svg │ │ ├── right.svg │ │ ├── select.svg │ │ ├── stretch-x.svg │ │ ├── stretch-y.svg │ │ ├── svgr │ │ │ ├── Arrow.tsx │ │ │ ├── Bottom.tsx │ │ │ ├── Box.tsx │ │ │ ├── CenterX.tsx │ │ │ ├── CenterY.tsx │ │ │ ├── Delete.tsx │ │ │ ├── DistributeX.tsx │ │ │ ├── DistributeY.tsx │ │ │ ├── FlipArrow.tsx │ │ │ ├── InvertArrow.tsx │ │ │ ├── Left.tsx │ │ │ ├── Redo.tsx │ │ │ ├── Right.tsx │ │ │ ├── Select.tsx │ │ │ ├── StretchX.tsx │ │ │ ├── StretchY.tsx │ │ │ ├── Top.tsx │ │ │ ├── Undo.tsx │ │ │ └── index.tsx │ │ ├── top.svg │ │ └── undo.svg │ ├── styled.tsx │ └── toolbar.tsx └── utils.tsx ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── _app.js ├── api │ └── hello.js └── index.tsx ├── public ├── favicon.ico ├── service.worker.js └── vercel.svg ├── styles └── globals.css ├── tsconfig.json ├── types.ts ├── yarn-error.log └── yarn.lock /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/arrows-playground/2a7160257f9fd0176d29bb4c61490e8c46c8b208/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next/* 3 | 4 | .vercel 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": true, 5 | "semi": false, 6 | "singleQuote": false, 7 | "trailingComma": "es5", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "fluid": false 11 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | ## Learn More 18 | 19 | To learn more about Next.js, take a look at the following resources: 20 | 21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 22 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 23 | 24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 25 | 26 | ## Deploy on Vercel 27 | 28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 29 | 30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 31 | -------------------------------------------------------------------------------- /components/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { styled } from "./theme" 3 | 4 | import useKeyboardEvents from "./hooks/useKeyboardEvents" 5 | import useWindowEvents from "./hooks/useWindowEvents" 6 | import useViewBox from "./hooks/useViewBox" 7 | 8 | import Toolbar from "./toolbar/toolbar" 9 | import ZoomIndicator from "./overlays/zoom-indicator" 10 | import Overlays from "./overlays/overlays" 11 | import Canvas from "./canvas-pixi/canvas" 12 | 13 | const Container = styled.div({ 14 | width: "100vw", 15 | height: "100vh", 16 | position: "absolute", 17 | top: 0, 18 | left: 0, 19 | }) 20 | 21 | export default function App() { 22 | const { ref, width, height } = useViewBox() 23 | 24 | useWindowEvents() 25 | useKeyboardEvents() 26 | 27 | // React.useEffect(() => { 28 | // Main.init() 29 | // }, []) 30 | 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /components/canvas-pixi/canvas.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { memo, useRef, useEffect } from "react" 3 | import Surface from "./surface" 4 | import state from "../state" 5 | import { styled } from "../theme" 6 | import * as PIXI from "pixi.js" 7 | 8 | const dpr = window.devicePixelRatio || 1 9 | 10 | let app: PIXI.Application 11 | 12 | const CanvasBackground = styled.div({ 13 | width: "100vw", 14 | height: "100vh", 15 | overflow: "hidden", 16 | bg: "$canvas", 17 | }) 18 | 19 | type Props = React.HTMLProps & { 20 | width: number 21 | height: number 22 | } 23 | 24 | function Canvas({ width, height, ...rest }: Props) { 25 | const rSurface = useRef() 26 | const rBackground = useRef(null) 27 | const rCanvas = useRef(null) 28 | 29 | useEffect(() => { 30 | if (rSurface.current) rSurface.current.destroy() 31 | const canvas = rCanvas.current 32 | const bg = rBackground.current 33 | if (!(canvas && bg)) return 34 | 35 | app = new PIXI.Application({ 36 | resolution: window.devicePixelRatio, 37 | view: canvas, 38 | }) 39 | 40 | app.resizeTo = bg 41 | app.resize() 42 | 43 | rSurface.current = new Surface(canvas, app) 44 | state.send("UPDATED_SURFACE", rSurface.current) 45 | }, [rCanvas]) 46 | 47 | useEffect(() => { 48 | app.resize() 49 | }, [width, height]) 50 | 51 | function handleWheel(e: React.WheelEvent) { 52 | const { deltaX, deltaY } = e 53 | 54 | if (e.ctrlKey) { 55 | // Zooming 56 | state.send("ZOOMED", deltaY / 100) 57 | state.send("MOVED_POINTER") 58 | } else { 59 | // Panning 60 | state.send("PANNED", { 61 | x: deltaX, 62 | y: deltaY, 63 | }) 64 | state.send("MOVED_POINTER") 65 | } 66 | } 67 | 68 | return ( 69 | { 73 | const surface = rSurface.current 74 | if (!surface) return 75 | 76 | const { hit } = surface 77 | 78 | switch (hit.type) { 79 | case "bounds": { 80 | state.send("STARTED_POINTING_BOUNDS") 81 | break 82 | } 83 | case "box": { 84 | state.send("STARTED_POINTING_BOX", { id: hit.id }) 85 | break 86 | } 87 | case "bounds-corner": { 88 | state.send("STARTED_POINTING_BOUNDS_CORNER", hit.corner) 89 | break 90 | } 91 | case "bounds-edge": { 92 | state.send("STARTED_POINTING_BOUNDS_EDGE", hit.edge) 93 | break 94 | } 95 | case "canvas": { 96 | state.send("STARTED_POINTING_CANVAS") 97 | break 98 | } 99 | } 100 | }} 101 | onPointerMove={(e) => 102 | state.send("MOVED_POINTER", { x: e.clientX, y: e.clientY }) 103 | } 104 | onPointerUp={(e) => 105 | state.send("STOPPED_POINTING", { x: e.clientX, y: e.clientY }) 106 | } 107 | > 108 | 117 | 118 | ) 119 | } 120 | 121 | export default memo(Canvas) 122 | -------------------------------------------------------------------------------- /components/canvas-pixi/surface.ts: -------------------------------------------------------------------------------- 1 | import * as PIXI from "pixi.js" 2 | import { doBoxesCollide, pointInRectangle, getCorners, camera } from "../utils" 3 | import { getArrow, getBoxToBoxArrow } from "perfect-arrows" 4 | import { 5 | IBox, 6 | IPoint, 7 | IBounds, 8 | IBrush, 9 | IFrame, 10 | IArrow, 11 | IArrowType, 12 | } from "../../types" 13 | import state, { pointerState, steady } from "../state" 14 | import * as Comlink from "comlink" 15 | 16 | const arrowCache: number[][] = [] 17 | 18 | const PI2 = Math.PI * 2 19 | 20 | const dpr = window.devicePixelRatio || 1 21 | 22 | export enum HitType { 23 | Canvas = "canvas", 24 | Bounds = "bounds", 25 | BoundsCorner = "bounds-corner", 26 | BoundsEdge = "bounds-edge", 27 | Box = "box", 28 | } 29 | 30 | export type Hit = 31 | | { type: HitType.Canvas } 32 | | { type: HitType.Bounds } 33 | | { type: HitType.BoundsCorner; corner: number } 34 | | { type: HitType.BoundsEdge; edge: number } 35 | | { type: HitType.Box; id: string } 36 | 37 | type ServiceRequest = (type: string, payload: any) => Promise 38 | 39 | const getFromWorker = Comlink.wrap( 40 | new Worker("service.worker.js") 41 | ) 42 | 43 | class Surface { 44 | _lineWidth = 2 45 | _stroke: string 46 | _fill: string 47 | _unsub: () => void 48 | _diffIndex = 0 49 | _looping = true 50 | cvs: HTMLCanvasElement 51 | ctx: CanvasRenderingContext2D 52 | 53 | allBoxes: IBox[] = [] 54 | hit: Hit = { type: HitType.Canvas } 55 | 56 | app: PIXI.Application 57 | graphics: PIXI.Graphics 58 | scale: PIXI.ObservablePoint 59 | 60 | state = state 61 | hoveredId = "" 62 | 63 | constructor(canvas: HTMLCanvasElement, app: PIXI.Application) { 64 | this.cvs = canvas 65 | this.app = app 66 | this.graphics = new PIXI.Graphics() 67 | 68 | this.app.renderer.backgroundColor = 0xefefef 69 | this.scale = new PIXI.ObservablePoint( 70 | () => {}, 71 | this.app, 72 | state.data.camera.zoom * dpr, 73 | state.data.camera.zoom * dpr 74 | ) 75 | 76 | const setup = () => { 77 | const { graphics } = this 78 | //Start the game loop 79 | const boxes = Object.values(steady.boxes).sort((a, b) => b.z - a.z) 80 | getFromWorker("updateHitTree", boxes) 81 | 82 | graphics.lineStyle(1 / state.data.camera.zoom, 0x000000, 1) 83 | graphics.beginFill(0xffffff, 0.9) 84 | for (let box of boxes) { 85 | graphics.drawRect(box.x, box.y, box.width, box.height) 86 | } 87 | graphics.endFill() 88 | this.app.stage.addChild(graphics) 89 | 90 | this.app.ticker.add((delta) => gameLoop(delta)) 91 | } 92 | 93 | const setHit = async () => { 94 | this.hit = await getFromWorker("hitTest", { 95 | point: pointerState.data.document, 96 | bounds: steady.bounds, 97 | zoom: this.state.data.camera.zoom, 98 | }) 99 | this.cvs.style.setProperty("cursor", this.getCursor(this.hit)) 100 | } 101 | 102 | const gameLoop = (delta: number) => { 103 | this.setupCamera() 104 | 105 | if (state.isIn("selectingIdle")) { 106 | setHit() 107 | } 108 | 109 | let id = "" 110 | if (this.hit.type === "box") id = this.hit.id 111 | 112 | if (id !== this.hoveredId) { 113 | this.hoveredId = id 114 | if (state.index === this._diffIndex) { 115 | this.clear() 116 | this.draw() 117 | } 118 | } 119 | 120 | if (state.index === this._diffIndex) { 121 | return 122 | } 123 | 124 | if (state.isIn("selectingIdle")) { 125 | this.allBoxes = Object.values(steady.boxes) 126 | this.allBoxes = this.allBoxes.sort((a, b) => a.z - b.z) 127 | getFromWorker("updateHitTree", this.allBoxes) 128 | } 129 | 130 | this.clear() 131 | this.draw() 132 | 133 | this._diffIndex = state.index 134 | } 135 | 136 | this.app.loader.load(setup) 137 | 138 | this.app.start() 139 | } 140 | 141 | destroy() { 142 | this._looping = false 143 | this.app.destroy() 144 | } 145 | 146 | draw() { 147 | this.drawBoxes() 148 | this.drawBrush() 149 | this.drawSelection() 150 | 151 | // if (this.state.isInAny("dragging")) { 152 | // this.computeArrows() 153 | // } 154 | 155 | // this.drawArrows() 156 | // this.drawSelection() 157 | } 158 | 159 | setupCamera() { 160 | const { camera } = this.state.data 161 | 162 | this.graphics.setTransform( 163 | -camera.x, 164 | -camera.y, 165 | camera.zoom, 166 | camera.zoom, 167 | 0, 168 | 0, 169 | 0, 170 | 0, 171 | 0 172 | ) 173 | } 174 | 175 | forceCompute() { 176 | this.computeArrows() 177 | } 178 | 179 | renderCanvasThings() { 180 | this.stroke = "#000" 181 | this.fill = "rgba(255, 255, 255, .2)" 182 | 183 | for (let i = this.allBoxes.length - 1; i > -1; i--) { 184 | this.drawBox(this.allBoxes[i]) 185 | } 186 | 187 | const allSpawningBoxes = Object.values(steady.spawning.boxes) 188 | 189 | for (let box of allSpawningBoxes) { 190 | this.save() 191 | this.stroke = "blue" 192 | this.drawBox(box) 193 | this.restore() 194 | } 195 | } 196 | 197 | drawBoxes() { 198 | const { graphics } = this 199 | const boxes = Object.values(steady.boxes) 200 | graphics.lineStyle(1 / this.state.data.camera.zoom, 0x000000, 1) 201 | graphics.beginFill(0xffffff, 0.9) 202 | 203 | for (let box of boxes) { 204 | graphics.drawRect(box.x, box.y, box.width, box.height) 205 | } 206 | 207 | const allSpawningBoxes = Object.values(steady.spawning.boxes) 208 | if (allSpawningBoxes.length > 0) { 209 | graphics.lineStyle(1 / this.state.data.camera.zoom, 0x0000ff, 1) 210 | 211 | for (let box of allSpawningBoxes) { 212 | graphics.drawRect(box.x, box.y, box.width, box.height) 213 | } 214 | } 215 | 216 | graphics.endFill() 217 | } 218 | 219 | drawSelection() { 220 | const { graphics } = this 221 | const { boxes, bounds } = steady 222 | const { camera, selectedBoxIds } = this.state.data 223 | 224 | graphics.lineStyle(1 / camera.zoom, 0x0000ff, 1) 225 | 226 | if (selectedBoxIds.length > 0) { 227 | // draw box outlines 228 | for (let id of selectedBoxIds) { 229 | let box = boxes[id] 230 | graphics.drawRect(box.x, box.y, box.width, box.height) 231 | } 232 | } 233 | 234 | if ( 235 | bounds && 236 | selectedBoxIds.length > 0 && 237 | !this.state.isIn("brushSelecting") 238 | ) { 239 | // draw bounds outline 240 | graphics.drawRect(bounds.x, bounds.y, bounds.width, bounds.height) 241 | graphics.beginFill(0x0000ff, 1) 242 | for (let [x, y] of getCorners( 243 | bounds.x, 244 | bounds.y, 245 | bounds.width, 246 | bounds.height 247 | )) { 248 | graphics.drawCircle(x, y, 3 / camera.zoom) 249 | } 250 | graphics.endFill() 251 | } 252 | 253 | if (this.hit.type === "box") { 254 | graphics.lineStyle(1.5 / camera.zoom, 0x0000ff, 1) 255 | const box = steady.boxes[this.hit.id] 256 | if (!box) { 257 | this.hit = { type: HitType.Canvas } 258 | } else { 259 | graphics.drawRect(box.x, box.y, box.width, box.height) 260 | } 261 | } 262 | } 263 | 264 | hitTest(): Hit { 265 | const point = pointerState.data.document 266 | const { bounds } = steady 267 | const { camera, viewBox } = this.state.data 268 | 269 | if (bounds) { 270 | // Test if point collides the (padded) bounds 271 | if (pointInRectangle(point, bounds, 16)) { 272 | const { x, y, width, height, maxX, maxY } = bounds 273 | const p = 5 / camera.zoom 274 | const pp = p * 2 275 | 276 | const cornerBoxes = [ 277 | { x: x - p, y: y - p, width: pp, height: pp }, 278 | { x: maxX - p, y: y - p, width: pp, height: pp }, 279 | { x: maxX - p, y: maxY - p, width: pp, height: pp }, 280 | { x: x - p, y: maxY - p, width: pp, height: pp }, 281 | ] 282 | 283 | for (let i = 0; i < cornerBoxes.length; i++) { 284 | if (pointInRectangle(point, cornerBoxes[i])) { 285 | return { type: HitType.BoundsCorner, corner: i } 286 | } 287 | } 288 | 289 | const edgeBoxes = [ 290 | { x: x + p, y: y - p, width: width - pp, height: pp }, 291 | { x: maxX - p, y: y + p, width: pp, height: height - pp }, 292 | { x: x + p, y: maxY - p, width: width - pp, height: pp }, 293 | { x: x - p, y: y + p, width: pp, height: height - pp }, 294 | ] 295 | 296 | for (let i = 0; i < edgeBoxes.length; i++) { 297 | if (pointInRectangle(point, edgeBoxes[i])) { 298 | return { type: HitType.BoundsEdge, edge: i } 299 | } 300 | } 301 | // Point is in the middle of the bounds 302 | return { type: HitType.Bounds } 303 | } 304 | } 305 | 306 | // Either we don't have bounds or we're out of bounds 307 | for (let box of this.allBoxes.filter((box) => 308 | doBoxesCollide(box, viewBox.document) 309 | )) { 310 | // Test if point collides the (padded) box 311 | if (pointInRectangle(point, box)) { 312 | // Point is in the middle of the box 313 | return { type: HitType.Box, id: box.id } 314 | } 315 | } 316 | 317 | return { type: HitType.Canvas } 318 | } 319 | 320 | clear() { 321 | // Reset transform? 322 | this.graphics.clear() 323 | } 324 | 325 | drawBox(box: IBox | IFrame) { 326 | const { ctx } = this 327 | const { x, y, width, height } = box 328 | const path = new Path2D() 329 | path.rect(x, y, width, height) 330 | ctx.fill(path) 331 | ctx.stroke(path) 332 | } 333 | 334 | drawDot(x: number, y: number, radius = 4) { 335 | const r = radius / this.state.data.camera.zoom 336 | const { ctx } = this 337 | ctx.beginPath() 338 | ctx.ellipse(x, y, r, r, 0, 0, PI2, false) 339 | ctx.fill() 340 | } 341 | 342 | drawEdge(start: IPoint, end: IPoint) { 343 | const { ctx } = this 344 | ctx.beginPath() 345 | ctx.moveTo(start.x, start.y) 346 | ctx.lineTo(end.x, end.y) 347 | ctx.stroke() 348 | } 349 | 350 | drawBrush() { 351 | const { graphics } = this 352 | const { brush } = steady 353 | 354 | if (!brush) return 355 | 356 | const { x0, y0, x1, y1 } = brush 357 | graphics.lineStyle(1 / this.state.data.camera.zoom, 0x00aaff, 1) 358 | graphics.beginFill(0x00aaff, 0.05) 359 | graphics.drawRect( 360 | Math.min(x1, x0), 361 | Math.min(y1, y0), 362 | Math.abs(x1 - x0), 363 | Math.abs(y1 - y0) 364 | ) 365 | graphics.endFill() 366 | } 367 | 368 | computeArrows() { 369 | const { arrows, boxes } = steady 370 | let sx: number, 371 | sy: number, 372 | cx: number, 373 | cy: number, 374 | ex: number, 375 | ey: number, 376 | ea: number 377 | 378 | arrowCache.length = 0 379 | 380 | for (let id in arrows) { 381 | const arrow = arrows[id] 382 | 383 | switch (arrow.type) { 384 | case IArrowType.BoxToBox: { 385 | const from = boxes[arrow.from] 386 | const to = boxes[arrow.to] 387 | if (from.id === to.id) { 388 | } 389 | // Box to Box Arrow 390 | ;[sx, sy, cx, cy, ex, ey, ea] = getBoxToBoxArrow( 391 | from.x, 392 | from.y, 393 | from.width, 394 | from.height, 395 | to.x, 396 | to.y, 397 | to.width, 398 | to.height 399 | ) 400 | 401 | break 402 | } 403 | case IArrowType.BoxToPoint: { 404 | const from = boxes[arrow.from] 405 | const to = arrow.to 406 | // Box to Box Arrow 407 | ;[sx, sy, cx, cy, ex, ey, ea] = getBoxToBoxArrow( 408 | from.x, 409 | from.y, 410 | from.width, 411 | from.height, 412 | to.x, 413 | to.y, 414 | 1, 415 | 1 416 | ) 417 | 418 | break 419 | } 420 | case IArrowType.PointToBox: { 421 | const from = arrow.from 422 | const to = boxes[arrow.to] 423 | // Box to Box Arrow 424 | ;[sx, sy, cx, cy, ex, ey, ea] = getBoxToBoxArrow( 425 | from.x, 426 | from.y, 427 | 1, 428 | 1, 429 | to.x, 430 | to.y, 431 | to.width, 432 | to.height 433 | ) 434 | 435 | break 436 | } 437 | case IArrowType.PointToPoint: { 438 | const { from, to } = arrow 439 | // Box to Box Arrow 440 | ;[sx, sy, cx, cy, ex, ey, ea] = getArrow(from.x, from.y, to.x, to.y) 441 | 442 | break 443 | } 444 | } 445 | 446 | arrowCache.push([sx, sy, cx, cy, ex, ey, ea]) 447 | } 448 | } 449 | 450 | drawArrows() { 451 | const { zoom } = this.state.data.camera 452 | 453 | // for (let [sx, sy, cx, cy, ex, ey, ea] of arrowCache) { 454 | // const { ctx } = this 455 | // ctx.save() 456 | // this.stroke = "#000" 457 | // this.fill = "#000" 458 | // this.lineWidth = 1 / zoom 459 | // ctx.beginPath() 460 | // ctx.moveTo(sx, sy) 461 | // ctx.quadraticCurveTo(cx, cy, ex, ey) 462 | // ctx.stroke() 463 | // this.drawDot(sx, sy) 464 | // this.drawArrowhead(ex, ey, ea) 465 | // ctx.restore() 466 | // } 467 | } 468 | 469 | drawArrowhead(x: number, y: number, angle: number) { 470 | const { ctx } = this 471 | const r = 5 / this.state.data.camera.zoom 472 | ctx.save() 473 | ctx.translate(x, y) 474 | ctx.rotate(angle) 475 | ctx.beginPath() 476 | ctx.moveTo(0, -r) 477 | ctx.lineTo(r * 2, 0) 478 | ctx.lineTo(0, r) 479 | ctx.closePath() 480 | ctx.fill() 481 | ctx.restore() 482 | } 483 | 484 | getCursor(hit: Hit) { 485 | const { isIn } = this.state 486 | 487 | if (isIn("dragging")) return "none" 488 | 489 | switch (hit.type) { 490 | case "box": 491 | case "bounds": { 492 | return "default" 493 | } 494 | case "bounds-corner": { 495 | return hit.corner % 2 === 0 ? "nwse-resize" : "nesw-resize" 496 | } 497 | case "bounds-edge": { 498 | return hit.edge % 2 === 0 ? "ns-resize" : "ew-resize" 499 | } 500 | case "canvas": { 501 | return "default" 502 | } 503 | } 504 | 505 | return "default" 506 | } 507 | 508 | save() { 509 | this.ctx.save() 510 | } 511 | 512 | restore() { 513 | this.ctx.restore() 514 | } 515 | 516 | resize() { 517 | this.app.resize() 518 | } 519 | 520 | // Getters / Setters ---------------- 521 | 522 | get stroke() { 523 | return this._stroke 524 | } 525 | 526 | set stroke(color: string) { 527 | this._stroke = color 528 | this.ctx.strokeStyle = color 529 | } 530 | 531 | get fill() { 532 | return this._fill 533 | } 534 | 535 | set fill(color: string) { 536 | this._fill = color 537 | this.ctx.fillStyle = color 538 | } 539 | 540 | get lineWidth() { 541 | return this._lineWidth 542 | } 543 | 544 | set lineWidth(width: number) { 545 | this._lineWidth = width 546 | this.ctx.lineWidth = width 547 | } 548 | } 549 | 550 | export default Surface 551 | -------------------------------------------------------------------------------- /components/hooks/useKeyboardEvents.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | import { handleKeyDown, handleKeyUp } from "../utils" 3 | 4 | export default function useKeyboardEvents() { 5 | useEffect(() => { 6 | window.addEventListener("keydown", handleKeyDown) 7 | window.addEventListener("keyup", handleKeyUp) 8 | 9 | return () => { 10 | window.removeEventListener("keydown", handleKeyDown) 11 | window.removeEventListener("keyup", handleKeyUp) 12 | } 13 | }, []) 14 | } 15 | -------------------------------------------------------------------------------- /components/hooks/useViewBox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import useResizeObserver from "use-resize-observer" 3 | import state from "../state" 4 | 5 | export default function useViewBox() { 6 | // Resize Observer 7 | const { ref, width = 0, height = 0 } = useResizeObserver() 8 | 9 | /** 10 | * Update the state when the element moves or resizes. Resizing is tracked automatically, 11 | * however you'll need to call `handleMove()` manually if you change the position of the 12 | * viewBox's element yourself. (Consider throttling or debouncing the call.) 13 | */ 14 | const handleMove = React.useCallback(() => { 15 | const container = ref.current 16 | if (!container) return 17 | 18 | const bounds = container.getBoundingClientRect() 19 | 20 | state.send("UPDATED_VIEWBOX", { 21 | x: bounds.left, 22 | y: bounds.top, 23 | width, 24 | height, 25 | }) 26 | }, [ref, width, height]) 27 | 28 | // Run handleMove automatically whenever the element resizes 29 | React.useEffect(handleMove, [ref, width, height, handleMove]) 30 | 31 | // Prevent the browser's built-in pinch zooming 32 | React.useEffect(() => { 33 | const container = ref.current 34 | if (!container) return 35 | 36 | function stopZoom(e: WheelEvent | TouchEvent) { 37 | if (e.ctrlKey) e.preventDefault() 38 | } 39 | 40 | container.addEventListener("wheel", stopZoom, { passive: false }) 41 | container.addEventListener("touchmove", stopZoom, { passive: false }) 42 | 43 | return () => { 44 | container.removeEventListener("wheel", stopZoom) 45 | container.removeEventListener("touchmove", stopZoom) 46 | } 47 | }, [ref]) 48 | 49 | return { ref, width, height, handleMove } 50 | } 51 | -------------------------------------------------------------------------------- /components/hooks/useWindowEvents.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import state from "../state" 3 | 4 | export default function useWindowEvents() { 5 | React.useEffect(() => { 6 | function handlePointerMove(e: PointerEvent) { 7 | state.send("MOVED_POINTER", { x: e.clientX, y: e.clientY }) 8 | } 9 | 10 | function handlePointerUp(e: PointerEvent) { 11 | state.send("STOPPED_POINTING", { x: e.clientX, y: e.clientY }) 12 | } 13 | 14 | function handlePointerDown(e: PointerEvent) { 15 | state.send("STARTED_POINTING", { x: e.clientX, y: e.clientY }) 16 | } 17 | 18 | function handleScroll() { 19 | state.send("SCROLLED_VIEWPORT", { x: window.scrollX, y: window.scrollY }) 20 | } 21 | 22 | window.addEventListener("pointermove", handlePointerMove) 23 | window.addEventListener("pointerup", handlePointerUp) 24 | window.addEventListener("pointerdown", handlePointerDown) 25 | window.addEventListener("scroll", handleScroll) 26 | 27 | return () => { 28 | window.removeEventListener("pointermove", handlePointerMove) 29 | window.removeEventListener("pointerup", handlePointerUp) 30 | window.removeEventListener("pointerdown", handlePointerDown) 31 | window.removeEventListener("scroll", handleScroll) 32 | } 33 | }, []) 34 | } 35 | -------------------------------------------------------------------------------- /components/overlays/button.tsx: -------------------------------------------------------------------------------- 1 | type Props = {}; 2 | 3 | export default function Button(props: Props) {} 4 | -------------------------------------------------------------------------------- /components/overlays/model.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import state from "../state" 3 | import { useStateDesigner } from "@state-designer/react" 4 | 5 | export default function Model() { 6 | const local = useStateDesigner(state) 7 | 8 | const { camera } = local.data 9 | 10 | return ( 11 |
24 |
31 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | Document 46 | 47 | 48 | 49 | 58 | 59 | ViewBox 60 | 61 | 62 | 63 | 64 | 65 | 66 | Document 67 | 68 | 69 | 70 | 71 |
72 |
73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /components/overlays/overlays.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Positions from "./positions" 3 | import state from "../state" 4 | 5 | export default function Overlays() { 6 | const [showPositions, setShowPositions] = React.useState(true) 7 | 8 | return ( 9 |
18 | { 25 | state.send("RESET_BOXES", e.currentTarget.value) 26 | }} 27 | /> 28 | {showPositions && } 29 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /components/overlays/positions.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { useStateDesigner } from "@state-designer/react" 3 | import state, { pointerState } from "../state" 4 | import Value from "./value" 5 | 6 | export default function Positions() { 7 | const pointer = useStateDesigner(pointerState) 8 | const local = useStateDesigner(state) 9 | const { camera, viewBox } = local.data 10 | const { screen, document } = pointer.data 11 | 12 | return ( 13 |
24 | {/* 25 | {Object.keys(boxes).length} 26 | 27 |
Boxes
28 | 29 | {selectedBoxIds.length} 30 | 31 |
Selected
*/} 32 | 33 | {Math.trunc(camera.x)} 34 | {Math.trunc(camera.y)} 35 | {camera.zoom.toFixed(2)} 36 |
Camera
37 | 38 | {Math.trunc(viewBox.width)} 39 | {Math.trunc(viewBox.height)} 40 |
View Box
41 | 42 | {Math.trunc(viewBox.width / camera.zoom)} 43 | {Math.trunc(viewBox.height / camera.zoom)} 44 |
Camera Frame
45 | 46 | {Math.trunc(document.x)} 47 | {Math.trunc(document.y)} 48 |
Pointer (Document)
49 | 50 | {Math.trunc(screen.x)} 51 | {Math.trunc(screen.y)} 52 |
Pointer (Screen)
53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /components/overlays/value.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | export default function Value({ 4 | label, 5 | children, 6 | style = {}, 7 | }: { 8 | label: string 9 | children: React.ReactNode 10 | style?: React.CSSProperties 11 | }) { 12 | return ( 13 |
26 | {children} 27 | {label} 28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /components/overlays/zoom-indicator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { useStateDesigner } from "@state-designer/react" 3 | import state from "../state" 4 | 5 | export default function ZoomIndicator() { 6 | const local = useStateDesigner(state) 7 | const { zoom } = local.data.camera 8 | 9 | return ( 10 | 19 | {Math.trunc(zoom * 100)}% 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /components/state/box-selecter.tsx: -------------------------------------------------------------------------------- 1 | import { IPoint, IBox } from "../../types" 2 | import RBush from "rbush" 3 | 4 | class Bush extends RBush<{ 5 | id: string 6 | minX: number 7 | minY: number 8 | maxX: number 9 | maxY: number 10 | }> {} 11 | 12 | const tree = new Bush() 13 | 14 | export function getBoxSelecter(initialBoxes: IBox[], origin: IPoint) { 15 | let x0: number, y0: number, x1: number, y1: number, t: number 16 | const { x: ox, y: oy } = origin 17 | 18 | tree.clear() 19 | 20 | tree.load( 21 | initialBoxes.map((box) => ({ 22 | id: box.id, 23 | minX: box.x, 24 | minY: box.y, 25 | maxX: box.x + box.width, 26 | maxY: box.y + box.height, 27 | })) 28 | ) 29 | 30 | return function select(point: IPoint) { 31 | x0 = ox 32 | y0 = oy 33 | x1 = point.x 34 | y1 = point.y 35 | 36 | if (x1 < x0) { 37 | t = x0 38 | x0 = x1 39 | x1 = t 40 | } 41 | 42 | if (y1 < y0) { 43 | t = y0 44 | y0 = y1 45 | y1 = t 46 | } 47 | 48 | const results = tree 49 | .search({ minX: x0, minY: y0, maxX: x1, maxY: y1 }) 50 | .map((b) => b.id) 51 | 52 | return results 53 | } 54 | } 55 | 56 | export type BoxSelecter = ReturnType 57 | -------------------------------------------------------------------------------- /components/state/box-transforms.tsx: -------------------------------------------------------------------------------- 1 | import { IBoxSnapshot, IPoint, IBounds, IBox } from "../../types" 2 | 3 | export function stretchBoxesX(boxes: IBox[]) { 4 | const [first, ...rest] = boxes 5 | let min = first.x 6 | let max = first.x + first.width 7 | for (let box of rest) { 8 | min = Math.min(min, box.x) 9 | max = Math.max(max, box.x + box.width) 10 | } 11 | for (let box of boxes) { 12 | box.x = min 13 | box.width = max - min 14 | } 15 | } 16 | export function stretchBoxesY(boxes: IBox[]) { 17 | const [first, ...rest] = boxes 18 | let min = first.y 19 | let max = first.y + first.height 20 | for (let box of rest) { 21 | min = Math.min(min, box.y) 22 | max = Math.max(max, box.y + box.height) 23 | } 24 | for (let box of boxes) { 25 | box.y = min 26 | box.height = max - min 27 | } 28 | } 29 | export function distributeBoxesX(boxes: IBox[]) { 30 | const [first, ...rest] = boxes 31 | let min = first.x 32 | let max = first.x + first.width 33 | let sum = first.width 34 | 35 | for (let box of rest) { 36 | min = Math.min(min, box.x) 37 | max = Math.max(max, box.x + box.width) 38 | sum += box.width 39 | } 40 | 41 | let t = min 42 | const gap = (max - min - sum) / (boxes.length - 1) 43 | for (let box of [...boxes].sort((a, b) => a.x - b.x)) { 44 | box.x = t 45 | t += box.width + gap 46 | } 47 | } 48 | export function distributeBoxesY(boxes: IBox[]) { 49 | const len = boxes.length 50 | const sorted = [...boxes].sort((a, b) => a.y - b.y) 51 | let min = sorted[0].y 52 | 53 | sorted.sort((a, b) => a.y + a.height - b.y - b.height) 54 | let last = sorted[len - 1] 55 | let max = last.y + last.height 56 | 57 | let range = max - min 58 | let step = range / len 59 | let box: IBox 60 | for (let i = 0; i < len - 1; i++) { 61 | box = sorted[i] 62 | box.y = min + step * i 63 | } 64 | } 65 | export function alignBoxesCenterX(boxes: IBox[]) { 66 | let midX = 0 67 | for (let box of boxes) { 68 | midX += box.x + box.width / 2 69 | } 70 | midX /= boxes.length 71 | for (let box of boxes) box.x = midX - box.width / 2 72 | } 73 | export function alignBoxesCenterY(boxes: IBox[]) { 74 | let midY = 0 75 | for (let box of boxes) midY += box.y + box.height / 2 76 | midY /= boxes.length 77 | for (let box of boxes) box.y = midY - box.height / 2 78 | } 79 | 80 | export function alignBoxesTop(boxes: IBox[]) { 81 | const [first, ...rest] = boxes 82 | let y = first.y 83 | for (let box of rest) if (box.y < y) y = box.y 84 | for (let box of boxes) box.y = y 85 | } 86 | 87 | export function alignBoxesBottom(boxes: IBox[]) { 88 | const [first, ...rest] = boxes 89 | let maxY = first.y + first.height 90 | for (let box of rest) if (box.y + box.height > maxY) maxY = box.y + box.height 91 | for (let box of boxes) box.y = maxY - box.height 92 | } 93 | 94 | export function alignBoxesLeft(boxes: IBox[]) { 95 | const [first, ...rest] = boxes 96 | let x = first.x 97 | for (let box of rest) if (box.x < x) x = box.x 98 | for (let box of boxes) box.x = x 99 | } 100 | 101 | export function alignBoxesRight(boxes: IBox[]) { 102 | const [first, ...rest] = boxes 103 | let maxX = first.x + first.width 104 | for (let box of rest) if (box.x + box.width > maxX) maxX = box.x + box.width 105 | for (let box of boxes) box.x = maxX - box.width 106 | } 107 | 108 | export function getBoundingBox(boxes: IBox[]): IBounds { 109 | if (boxes.length === 0) { 110 | return { 111 | x: 0, 112 | y: 0, 113 | maxX: 0, 114 | maxY: 0, 115 | width: 0, 116 | height: 0, 117 | } 118 | } 119 | 120 | const first = boxes[0] 121 | 122 | let x = first.x 123 | let maxX = first.x + first.width 124 | let y = first.y 125 | let maxY = first.y + first.height 126 | 127 | for (let box of boxes) { 128 | x = Math.min(x, box.x) 129 | maxX = Math.max(maxX, box.x + box.width) 130 | y = Math.min(y, box.y) 131 | maxY = Math.max(maxY, box.y + box.height) 132 | } 133 | 134 | return { 135 | x, 136 | y, 137 | maxX, 138 | maxY, 139 | width: maxX - x, 140 | height: maxY - y, 141 | } 142 | } 143 | 144 | function getSnapshots( 145 | boxes: IBox[], 146 | bounds: IBounds 147 | ): Record { 148 | const acc = {} as Record 149 | 150 | for (let box of boxes) { 151 | acc[box.id] = { 152 | ...box, 153 | nx: (box.x - bounds.x) / bounds.width, 154 | ny: (box.y - bounds.y) / bounds.height, 155 | nmx: 1 - (box.x + box.width - bounds.x) / bounds.width, 156 | nmy: 1 - (box.y + box.height - bounds.y) / bounds.height, 157 | nw: box.width / bounds.width, 158 | nh: box.height / bounds.height, 159 | } 160 | } 161 | 162 | return acc 163 | } 164 | 165 | export function getEdgeResizer( 166 | initialBoxes: IBox[], 167 | initialBounds: IBounds, 168 | edge: number 169 | ) { 170 | const snapshots = getSnapshots(initialBoxes, initialBounds) 171 | 172 | let { x: x0, y: y0, maxX: x1, maxY: y1 } = initialBounds 173 | let { x: mx, y: my, width: mw, height: mh } = initialBounds 174 | 175 | return function edgeResize(point: IPoint, boxes: IBox[], bounds: IBounds) { 176 | const { x, y } = point 177 | if (edge === 0 || edge === 2) { 178 | edge === 0 ? (y0 = y) : (y1 = y) 179 | my = y0 < y1 ? y0 : y1 180 | mh = Math.abs(y1 - y0) 181 | for (let box of boxes) { 182 | const { ny, nmy, nh } = snapshots[box.id] 183 | box.y = my + (y1 < y0 ? nmy : ny) * mh 184 | box.height = nh * mh 185 | } 186 | } else { 187 | edge === 1 ? (x1 = x) : (x0 = x) 188 | mx = x0 < x1 ? x0 : x1 189 | mw = Math.abs(x1 - x0) 190 | for (let box of boxes) { 191 | const { nx, nmx, nw } = snapshots[box.id] 192 | box.x = mx + (x1 < x0 ? nmx : nx) * mw 193 | box.width = nw * mw 194 | } 195 | } 196 | 197 | bounds.x = mx 198 | bounds.y = my 199 | bounds.width = mw 200 | bounds.height = mh 201 | bounds.maxX = mx + mw 202 | bounds.maxY = my + mh 203 | } 204 | } 205 | 206 | /** 207 | * Returns a function that can be used to calculate corner resize transforms. 208 | * @param boxes An array of the boxes being resized. 209 | * @param corner A number representing the corner being dragged. Top Left: 0, Top Right: 1, Bottom Right: 2, Bottom Left: 3. 210 | * @example 211 | * const resizer = getCornerResizer(selectedBoxes, 3) 212 | * resizer(selectedBoxes, ) 213 | */ 214 | export function getCornerResizer( 215 | initialBoxes: IBox[], 216 | initialBounds: IBounds, 217 | corner: number 218 | ) { 219 | const snapshots = getSnapshots(initialBoxes, initialBounds) 220 | 221 | let { x: x0, y: y0, maxX: x1, maxY: y1 } = initialBounds 222 | let { x: mx, y: my, width: mw, height: mh } = initialBounds 223 | 224 | return function cornerResizer(point: IPoint, boxes: IBox[], bounds: IBounds) { 225 | const { x, y } = point 226 | corner < 2 ? (y0 = y) : (y1 = y) 227 | my = y0 < y1 ? y0 : y1 228 | mh = Math.abs(y1 - y0) 229 | 230 | corner === 1 || corner === 2 ? (x1 = x) : (x0 = x) 231 | mx = x0 < x1 ? x0 : x1 232 | mw = Math.abs(x1 - x0) 233 | 234 | for (let box of boxes) { 235 | const { nx, nmx, nw, ny, nmy, nh } = snapshots[box.id] 236 | box.x = mx + (x1 < x0 ? nmx : nx) * mw 237 | box.y = my + (y1 < y0 ? nmy : ny) * mh 238 | box.width = nw * mw 239 | box.height = nh * mh 240 | } 241 | 242 | bounds.x = mx 243 | bounds.y = my 244 | bounds.width = mw 245 | bounds.height = mh 246 | bounds.maxX = mx + mw 247 | bounds.maxY = my + mh 248 | } 249 | } 250 | 251 | export type EdgeResizer = ReturnType 252 | export type CornerResizer = ReturnType 253 | -------------------------------------------------------------------------------- /components/state/database.ts: -------------------------------------------------------------------------------- 1 | import { IBox, IArrow, IArrowType } from "../../types" 2 | 3 | const RESET_LOCAL_DATA = true 4 | 5 | export const LOCAL_STORAGE_KEY = "perfect_arrows_example" 6 | 7 | /** 8 | * Save something to the "database" 9 | * @param data 10 | */ 11 | export function saveToDatabase(data: string) { 12 | localStorage.setItem(LOCAL_STORAGE_KEY, data) 13 | } 14 | 15 | /** 16 | * Get the initial data for the store. 17 | */ 18 | export function getInitialData(): { 19 | boxes: Record 20 | arrows: Record 21 | } { 22 | let previous: string | null = null 23 | let initial: { 24 | boxes: Record 25 | arrows: Record 26 | } 27 | 28 | // if (typeof window !== undefined) { 29 | // if (typeof window.localStorage !== undefined) { 30 | // previous = localStorage.getItem(LOCAL_STORAGE_KEY) 31 | // } 32 | // } 33 | 34 | if (previous === null || RESET_LOCAL_DATA) { 35 | // Initial Boxes 36 | // const initBoxes = { 37 | // box_a0: { 38 | // id: "box_a0", 39 | // x: 100, 40 | // y: 100, 41 | // width: 100, 42 | // height: 100, 43 | // label: "", 44 | // color: "rgba(255, 255, 255, 1)", 45 | // z: 0, 46 | // }, 47 | // box_a1: { 48 | // id: "box_a1", 49 | // x: 200, 50 | // y: 300, 51 | // width: 100, 52 | // height: 100, 53 | // label: "", 54 | // color: "rgba(255, 255, 255, 1)", 55 | // z: 1, 56 | // }, 57 | // } 58 | 59 | // Stress Test! Can do about 5000 boxes easily. 60 | 61 | const initBoxes = Array.from(Array(5)) 62 | .map((_, i) => ({ 63 | id: "box_a" + i, 64 | x: 64 + Math.random() * 720, 65 | y: 64 + Math.random() * 400, 66 | width: 32 + Math.random() * 64, 67 | height: 32 + Math.random() * 64, 68 | label: "", 69 | color: "#FFF", 70 | z: i, 71 | })) 72 | .reduce((acc, cur) => { 73 | acc[cur.id] = cur 74 | return acc 75 | }, {}) 76 | 77 | // Initial Arrows 78 | const a = initBoxes["box_a0"] 79 | const b = initBoxes["box_a1"] 80 | 81 | const allBoxes = Object.values(initBoxes) 82 | 83 | const initArrows: Record = { 84 | arrow_a0: { 85 | id: "arrow_a0", 86 | type: IArrowType.BoxToBox, 87 | from: a.id, 88 | to: b.id, 89 | flip: false, 90 | label: "", 91 | }, 92 | arrow_a1: { 93 | id: "arrow_a1", 94 | type: IArrowType.BoxToBox, 95 | from: a.id, 96 | to: a.id, 97 | flip: false, 98 | label: "", 99 | }, 100 | arrow_a2: { 101 | id: "arrow_a2", 102 | type: IArrowType.BoxToPoint, 103 | from: a.id, 104 | to: { x: 300, y: 200 }, 105 | flip: false, 106 | label: "", 107 | }, 108 | arrow_a3: { 109 | id: "arrow_a3", 110 | type: IArrowType.PointToBox, 111 | from: { x: 100, y: 500 }, 112 | to: b.id, 113 | flip: false, 114 | label: "", 115 | }, 116 | arrow_a4: { 117 | id: "arrow_a4", 118 | type: IArrowType.PointToPoint, 119 | from: { x: 500, y: 800 }, 120 | to: { x: 200, y: 600 }, 121 | flip: false, 122 | label: "", 123 | }, 124 | } 125 | 126 | for (let i = 0; i < allBoxes.length; i++) { 127 | let boxA = initBoxes["box_a" + i] 128 | let boxB = initBoxes["box_a" + (i + 1)] 129 | if (!boxA || !boxB) continue 130 | 131 | initArrows["arrow_b" + i] = { 132 | id: "arrow_b" + i, 133 | type: IArrowType.BoxToBox, 134 | from: boxA.id, 135 | to: boxB.id, 136 | flip: false, 137 | label: "", 138 | } 139 | } 140 | 141 | initial = { 142 | boxes: initBoxes, 143 | arrows: initArrows, 144 | } 145 | } else { 146 | initial = JSON.parse(previous) 147 | } 148 | 149 | return initial 150 | } 151 | -------------------------------------------------------------------------------- /components/state/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { createState } from "@state-designer/react" 3 | import { 4 | IArrowType, 5 | IPoint, 6 | IBounds, 7 | IBrush, 8 | IBox, 9 | IFrame, 10 | IArrow, 11 | IBoxSnapshot, 12 | } from "../../types" 13 | // import Surface from "../canvas/surface" 14 | import { pressedKeys, viewBoxToCamera, getBoundingBox } from "../utils" 15 | import { getInitialData, saveToDatabase } from "./database" 16 | import { BoxSelecter, getBoxSelecter } from "./box-selecter" 17 | import * as BoxTransforms from "./box-transforms" 18 | import clamp from "lodash/clamp" 19 | import uniqueId from "lodash/uniqueId" 20 | import { v4 as uuid } from "uuid" 21 | 22 | import * as Comlink from "comlink" 23 | 24 | // type GetFromWorker = ( 25 | // type: "stretchBoxesX" | "stretchBoxesY", 26 | // payload: IBox[] 27 | // ) => Promise 28 | 29 | type GetFromWorker = (type: string, payload: any) => Promise 30 | 31 | const getFromWorker = Comlink.wrap( 32 | new Worker("service.worker.js") 33 | ) 34 | 35 | // let surface: Surface | undefined = undefined 36 | const id = uuid() 37 | 38 | function getId() { 39 | return uniqueId(id) 40 | } 41 | 42 | let selecter: BoxSelecter | undefined 43 | let resizer: BoxTransforms.EdgeResizer | BoxTransforms.CornerResizer | undefined 44 | const undos: string[] = [] 45 | const redos: string[] = [] 46 | 47 | export const pointerState = createState({ 48 | data: { screen: { x: 0, y: 0 }, document: { x: 0, y: 0 } }, 49 | on: { MOVED_POINTER: (d, p) => Object.assign(d, p) }, 50 | }) 51 | 52 | let prevB: any = {} 53 | 54 | export const steady = { 55 | ...getInitialData(), 56 | spawning: { 57 | boxes: {} as Record, 58 | arrows: {} as Record, 59 | clones: {} as Record, 60 | }, 61 | brush: undefined as IBrush | undefined, 62 | bounds: undefined as IBounds | undefined, 63 | initial: { 64 | pointer: { x: 0, y: 0 }, 65 | selected: { 66 | boxIds: [] as string[], 67 | arrowIds: [] as string[], 68 | }, 69 | boxes: {} as Record, 70 | }, 71 | } 72 | 73 | const state = createState({ 74 | data: { 75 | selectedArrowIds: [] as string[], 76 | selectedBoxIds: [] as string[], 77 | // surface: undefined as Surface | undefined, 78 | pointer: { 79 | x: 0, 80 | y: 0, 81 | dx: 0, 82 | dy: 0, 83 | }, 84 | camera: { 85 | x: 0, 86 | y: 0, 87 | zoom: 1, 88 | }, 89 | viewBox: { 90 | x: 0, 91 | y: 0, 92 | width: 0, 93 | height: 0, 94 | scrollX: 0, 95 | scrollY: 0, 96 | document: { 97 | x: 0, 98 | y: 0, 99 | width: 0, 100 | height: 0, 101 | }, 102 | }, 103 | }, 104 | onEnter: ["saveUndoState", "updateBounds"], 105 | on: { 106 | FORCED_IDS: (d, p) => (d.selectedBoxIds = p), 107 | RESET_BOXES: "resetBoxes", 108 | // UPDATED_SURFACE: (d, p) => (surface = p), 109 | UNDO: ["loadUndoState", "updateBounds"], 110 | REDO: ["loadRedoState", "updateBounds"], 111 | STARTED_POINTING: { secretlyDo: "setInitialPointer" }, 112 | MOVED_POINTER: { secretlyDo: "updatePointerOnPointerMove" }, 113 | ZOOMED: "updateCameraZoom", 114 | PANNED: ["updateCameraPoint", "updatePointerOnPan"], 115 | SCROLLED_VIEWPORT: "updateViewBoxOnScroll", 116 | UPDATED_VIEWBOX: ["updateCameraOnViewBoxChange", "updateViewBox"], 117 | }, 118 | initial: "selectTool", 119 | states: { 120 | selectTool: { 121 | initial: "selectingIdle", 122 | states: { 123 | selectingIdle: { 124 | on: { 125 | CANCELLED: "clearSelection", 126 | SELECTED_BOX_TOOL: { to: "boxTool" }, 127 | DELETED_SELECTED: { 128 | if: "hasSelected", 129 | do: [ 130 | "saveUndoState", 131 | "deleteSelected", 132 | "updateBounds", 133 | "saveUndoState", 134 | ], 135 | }, 136 | ALIGNED_LEFT: [ 137 | "alignSelectedBoxesLeft", 138 | "updateBounds", 139 | "saveUndoState", 140 | ], 141 | ALIGNED_RIGHT: [ 142 | "alignSelectedBoxesRight", 143 | "updateBounds", 144 | "saveUndoState", 145 | ], 146 | ALIGNED_CENTER_X: [ 147 | "alignSelectedBoxesCenterX", 148 | "updateBounds", 149 | "saveUndoState", 150 | ], 151 | ALIGNED_TOP: [ 152 | "alignSelectedBoxesTop", 153 | "updateBounds", 154 | "saveUndoState", 155 | ], 156 | ALIGNED_BOTTOM: [ 157 | "alignSelectedBoxesBottom", 158 | "updateBounds", 159 | "saveUndoState", 160 | ], 161 | ALIGNED_CENTER_Y: [ 162 | "alignSelectedBoxesCenterY", 163 | "updateBounds", 164 | "saveUndoState", 165 | ], 166 | DISTRIBUTED_X: [ 167 | "distributeSelectedBoxesX", 168 | "updateBounds", 169 | "saveUndoState", 170 | ], 171 | DISTRIBUTED_Y: [ 172 | "distributeSelectedBoxesY", 173 | "updateBounds", 174 | "saveUndoState", 175 | ], 176 | STRETCHED_X: [ 177 | "stretchSelectedBoxesX", 178 | "updateBounds", 179 | "saveUndoState", 180 | ], 181 | STRETCHED_Y: [ 182 | "stretchSelectedBoxesY", 183 | "updateBounds", 184 | "saveUndoState", 185 | ], 186 | STARTED_POINTING_BOUNDS_EDGE: { to: "edgeResizing" }, 187 | STARTED_POINTING_BOUNDS_CORNER: { to: "cornerResizing" }, 188 | STARTED_POINTING_CANVAS: { to: "pointingCanvas" }, 189 | STARTED_POINTING_BOX: [ 190 | { unless: "boxIsSelected", do: ["selectBox", "updateBounds"] }, 191 | { to: "dragging" }, 192 | ], 193 | STARTED_POINTING_BOUNDS: { to: "dragging" }, 194 | }, 195 | }, 196 | pointingCanvas: { 197 | on: { 198 | MOVED_POINTER: { if: "distanceIsFarEnough", to: "brushSelecting" }, 199 | STOPPED_POINTING: { 200 | do: ["clearSelection", "updateBounds"], 201 | to: "selectingIdle", 202 | }, 203 | }, 204 | }, 205 | brushSelecting: { 206 | onEnter: [ 207 | "clearSelection", 208 | "startBrushWithWorker", 209 | // "startBrush", 210 | "setInitialSelectedIds", 211 | ], 212 | on: { 213 | MOVED_POINTER: [ 214 | "moveBrush", 215 | "setSelectedIdsFromWorker", 216 | // { 217 | // get: "brushSelectingBoxes", 218 | // if: "selectionHasChanged", 219 | // do: ["setSelectedIds"], 220 | // }, 221 | ], 222 | STOPPED_POINTING: { 223 | do: ["completeBrush", "updateBounds"], 224 | to: "selectingIdle", 225 | }, 226 | }, 227 | }, 228 | dragging: { 229 | states: { 230 | dragIdle: { 231 | onEnter: ["setInitialPointer", "setInitialSnapshot"], 232 | on: { 233 | MOVED_POINTER: { 234 | do: ["moveDraggingBoxes", "moveBounds"], 235 | to: "dragActive", 236 | }, 237 | STOPPED_POINTING: { to: "selectingIdle" }, 238 | }, 239 | }, 240 | dragActive: { 241 | onExit: "saveUndoState", 242 | on: { 243 | MOVED_POINTER: ["moveDraggingBoxes", "moveBounds"], 244 | STOPPED_POINTING: { 245 | do: ["updateBounds"], 246 | to: "selectingIdle", 247 | }, 248 | }, 249 | }, 250 | }, 251 | }, 252 | edgeResizing: { 253 | initial: "edgeResizeIdle", 254 | states: { 255 | edgeResizeIdle: { 256 | onEnter: "setEdgeResizer", 257 | on: { 258 | MOVED_POINTER: { do: "resizeBounds", to: "edgeResizeActive" }, 259 | STOPPED_POINTING: { to: "selectingIdle" }, 260 | }, 261 | }, 262 | edgeResizeActive: { 263 | onExit: "saveUndoState", 264 | on: { 265 | MOVED_POINTER: { do: "resizeBounds" }, 266 | STOPPED_POINTING: { to: "selectingIdle" }, 267 | }, 268 | }, 269 | }, 270 | }, 271 | cornerResizing: { 272 | initial: "cornerResizeIdle", 273 | states: { 274 | cornerResizeIdle: { 275 | onEnter: "setCornerResizer", 276 | on: { 277 | MOVED_POINTER: { 278 | do: "resizeBounds", 279 | to: "cornerResizeActive", 280 | }, 281 | STOPPED_POINTING: { to: "selectingIdle" }, 282 | }, 283 | }, 284 | cornerResizeActive: { 285 | onExit: "saveUndoState", 286 | on: { 287 | MOVED_POINTER: { do: "resizeBounds" }, 288 | STOPPED_POINTING: { to: "selectingIdle" }, 289 | }, 290 | }, 291 | }, 292 | }, 293 | }, 294 | }, 295 | boxTool: { 296 | initial: "boxIdle", 297 | states: { 298 | boxIdle: { 299 | on: { 300 | SELECTED_SELECT_TOOL: { to: "selectTool" }, 301 | STARTED_POINTING: { to: "drawingBox" }, 302 | }, 303 | }, 304 | drawingBox: { 305 | initial: "drawingBoxIdle", 306 | onEnter: "setBoxOrigin", 307 | states: { 308 | drawingBoxIdle: { 309 | on: { 310 | MOVED_POINTER: { to: "drawingBoxActive" }, 311 | }, 312 | }, 313 | drawingBoxActive: { 314 | onEnter: ["saveUndoState", "clearSelection", "createDrawingBox"], 315 | onExit: ["completeDrawingBox", "saveUndoState"], 316 | on: { 317 | MOVED_POINTER: { do: "updateDrawingBox" }, 318 | STOPPED_POINTING: { to: "selectingIdle" }, 319 | }, 320 | }, 321 | }, 322 | }, 323 | }, 324 | }, 325 | // selected: { 326 | // on: { 327 | // DOWNED_POINTER: { do: "updateOrigin" }, 328 | // }, 329 | // initial: "selectedIdle", 330 | // states: { 331 | // selectedIdle: { 332 | // on: { 333 | // CANCELLED: { do: "clearSelection" }, 334 | // STARTED_CLICKING_BOX: { to: "clickingBox" }, 335 | // STARTED_CLICKING_CANVAS: { to: "clickingCanvas" }, 336 | // }, 337 | // }, 338 | // clickingCanvas: { 339 | // on: { 340 | // STOPPED_CLICKING_CANVAS: { 341 | // do: "clearSelection", 342 | // to: "selectedIdle", 343 | // }, 344 | // MOVED_POINTER: { if: "dragIsFarEnough", to: "brushSelecting" }, 345 | // }, 346 | // }, 347 | // clickingBox: { 348 | // onEnter: "setInitialSnapshot", 349 | // on: { 350 | // DRAGGED_BOX: { if: "dragIsFarEnough", to: "draggingBox" }, 351 | // }, 352 | // }, 353 | // clickingArrowNode: { 354 | // on: { 355 | // DRAGGED_ARROW_NODE: { if: "dragIsFarEnough", to: "drawingArrow" }, 356 | // RELEASED_ARROW_NODE: { to: "pickingArrow" }, 357 | // }, 358 | // }, 359 | // brushSelecting: { 360 | // onEnter: [ 361 | // "setInitialSelection", 362 | // "updateSelectionBrush", 363 | // { 364 | // if: "isInShiftMode", 365 | // to: "pushingToSelection", 366 | // else: { to: "settingSelection" }, 367 | // }, 368 | // ], 369 | // on: { 370 | // MOVED_POINTER: { do: "updateSelectionBrush" }, 371 | // SCROLLED: { do: "updateSelectionBrush" }, 372 | // RAISED_POINTER: { do: "completeSelection", to: "selectedIdle" }, 373 | // }, 374 | // initial: "settingSelection", 375 | // states: { 376 | // settingSelection: { 377 | // onEnter: { 378 | // get: "brushSelectingBoxes", 379 | // do: "setbrushSelectingToSelection", 380 | // }, 381 | // on: { 382 | // ENTERED_SHIFT_MODE: { to: "pushingToSelection" }, 383 | // MOVED_POINTER: { 384 | // get: "brushSelectingBoxes", 385 | // if: "brushSelectionHasChanged", 386 | // do: "setbrushSelectingToSelection", 387 | // }, 388 | // SCROLLED: { 389 | // get: "brushSelectingBoxes", 390 | // if: "brushSelectionHasChanged", 391 | // do: "setbrushSelectingToSelection", 392 | // }, 393 | // }, 394 | // }, 395 | // pushingToSelection: { 396 | // onEnter: { 397 | // get: "brushSelectingBoxes", 398 | // do: "pushbrushSelectingToSelection", 399 | // }, 400 | // on: { 401 | // EXITED_SHIFT_MODE: { to: "settingSelection" }, 402 | // MOVED_POINTER: { 403 | // get: "brushSelectingBoxes", 404 | // do: "pushbrushSelectingToSelection", 405 | // }, 406 | // SCROLLED: { 407 | // get: "brushSelectingBoxes", 408 | // do: "pushbrushSelectingToSelection", 409 | // }, 410 | // }, 411 | // }, 412 | // }, 413 | // }, 414 | // draggingBoxes: { 415 | // states: { 416 | // dragOperation: { 417 | // initial: "notCloning", 418 | // states: { 419 | // notCloning: { 420 | // onEnter: "clearDraggingBoxesClones", 421 | // on: { 422 | // ENTERED_OPTION_MODE: { to: "cloning" }, 423 | // RAISED_POINTER: { do: "completeSelectedBoxes" }, 424 | // CANCELLED: { 425 | // do: "restoreInitialBoxes", 426 | // to: "selectedIdle", 427 | // }, 428 | // }, 429 | // }, 430 | // cloning: { 431 | // onEnter: "createDraggingBoxesClones", 432 | // on: { 433 | // ENTERED_OPTION_MODE: { to: "notCloning" }, 434 | // RAISED_POINTER: { 435 | // do: ["completeSelectedBoxes", "completeBoxesFromClones"], 436 | // }, 437 | // CANCELLED: { 438 | // do: ["restoreInitialBoxes", "clearDraggingBoxesClones"], 439 | // to: "selectedIdle", 440 | // }, 441 | // }, 442 | // }, 443 | // }, 444 | // }, 445 | // axes: { 446 | // initial: "freeAxes", 447 | // states: { 448 | // freeAxes: { 449 | // onEnter: "updateDraggingBoxesToLockedAxes", 450 | // on: { 451 | // ENTERED_SHIFT_MODE: { to: "lockedAxes" }, 452 | // }, 453 | // }, 454 | // lockedAxes: { 455 | // onEnter: "updateDraggingBoxesToFreeAxes", 456 | // on: { 457 | // EXITED_SHIFT_MODE: { to: "freeAxes" }, 458 | // }, 459 | // }, 460 | // }, 461 | // }, 462 | // }, 463 | // }, 464 | // resizingBoxes: { 465 | // on: { 466 | // CANCELLED: { do: "restoreInitialBoxes", to: "selectedIdle" }, 467 | // RAISED_POINTER: { do: "completeSelectedBoxes" }, 468 | // }, 469 | // initial: "edgeResizing", 470 | // states: { 471 | // edgeResizing: { 472 | // on: { 473 | // MOVED_POINTER: { do: "cornerResizeSelectedBoxes" }, 474 | // SCROLLED: { do: "cornerResizeSelectedBoxes" }, 475 | // }, 476 | // }, 477 | // cornerResizing: { 478 | // on: { 479 | // MOVED_POINTER: { do: "edgeResizeSelectedBoxes" }, 480 | // SCROLLED: { do: "edgeResizeSelectedBoxes" }, 481 | // }, 482 | // initial: "freeRatio", 483 | // states: { 484 | // freeRatio: { 485 | // onEnter: "updateResizingBoxesToLockedRatio", 486 | // on: { 487 | // ENTERED_SHIFT_MODE: { to: "lockedRatio" }, 488 | // }, 489 | // }, 490 | // lockedRatio: { 491 | // onEnter: "updateResizingBoxesToFreeRatio", 492 | // on: { 493 | // EXITED_SHIFT_MODE: { to: "freeRatio" }, 494 | // }, 495 | // }, 496 | // }, 497 | // }, 498 | // }, 499 | // }, 500 | // creatingArrow: { 501 | // initial: "drawingArrow", 502 | // on: {}, 503 | // states: { 504 | // drawingArrow: {}, 505 | // pickingArrow: {}, 506 | // }, 507 | // }, 508 | // }, 509 | // }, 510 | // drawingBox: { 511 | // on: { 512 | // CANCELLED: { to: "selected" }, 513 | // }, 514 | // initial: "notDrawing", 515 | // states: { 516 | // notDrawing: {}, 517 | // }, 518 | // }, 519 | // pickingArrow: { 520 | // initial: "choosingFrom", 521 | // on: { 522 | // CANCELLED: { to: "selected" }, 523 | // }, 524 | // states: { 525 | // choosingFrom: {}, 526 | // choosingTo: {}, 527 | // }, 528 | // }, 529 | }, 530 | results: { 531 | brushSelectingBoxes(data) { 532 | const { camera, pointer, viewBox } = data 533 | 534 | const results = selecter 535 | ? selecter(viewBoxToCamera(pointer, viewBox, camera)) 536 | : [] 537 | 538 | return results 539 | }, 540 | }, 541 | conditions: { 542 | distanceIsFarEnough(data) { 543 | const { initial } = steady 544 | const { pointer } = data 545 | const dist = Math.hypot( 546 | pointer.x - initial.pointer.x, 547 | pointer.y - initial.pointer.y 548 | ) 549 | return dist > 4 550 | }, 551 | boxIsSelected(data, id: string) { 552 | return data.selectedBoxIds.includes(id) 553 | }, 554 | selectionHasChanged(data, _, ids: string[]) { 555 | return ids.length !== data.selectedBoxIds.length 556 | }, 557 | isInShiftMode() { 558 | return pressedKeys.Shift 559 | }, 560 | hasSelected(data) { 561 | return data.selectedBoxIds.length > 0 562 | }, 563 | }, 564 | actions: { 565 | // Pointer ------------------------ 566 | updatePointerOnPan(data, delta: IPoint) { 567 | const { pointer, viewBox, camera } = data 568 | pointer.dx = delta.x / camera.zoom 569 | pointer.dy = delta.y / camera.zoom 570 | pointerState.send("MOVED_POINTER", { 571 | screen: { ...pointer }, 572 | document: viewBoxToCamera(pointer, viewBox, camera), 573 | }) 574 | }, 575 | updatePointerOnPointerMove(data, point: IPoint) { 576 | if (!point) return // Probably triggered by a zoom / scroll 577 | const { camera, viewBox, pointer } = data 578 | pointer.dx = (point.x - pointer.x) / camera.zoom 579 | pointer.dy = (point.y - pointer.y) / camera.zoom 580 | pointer.x = point.x 581 | pointer.y = point.y 582 | pointerState.send("MOVED_POINTER", { 583 | screen: { ...pointer }, 584 | document: viewBoxToCamera(pointer, viewBox, camera), 585 | }) 586 | }, 587 | setInitialPointer(data) { 588 | const { initial } = steady 589 | const { pointer, viewBox, camera } = data 590 | initial.pointer = viewBoxToCamera(pointer, viewBox, camera) 591 | }, 592 | 593 | // Camera ------------------------- 594 | updateCameraZoom(data, change = 0) { 595 | const { camera, viewBox, pointer } = data 596 | const prev = camera.zoom 597 | const next = clamp(prev - change, 0.25, 100) 598 | const delta = next - prev 599 | camera.zoom = next 600 | camera.x += ((camera.x + pointer.x) * delta) / prev 601 | camera.y += ((camera.y + pointer.y) * delta) / prev 602 | 603 | viewBox.document.x = camera.x / camera.zoom 604 | viewBox.document.y = camera.y / camera.zoom 605 | viewBox.document.width = viewBox.width / camera.zoom 606 | viewBox.document.height = viewBox.height / camera.zoom 607 | }, 608 | updateCameraPoint(data, delta: IPoint) { 609 | const { camera, viewBox } = data 610 | camera.x += delta.x 611 | camera.y += delta.y 612 | viewBox.document.x += delta.x / camera.zoom 613 | viewBox.document.y += delta.y / camera.zoom 614 | }, 615 | updateCameraOnViewBoxChange(data, frame: IFrame) { 616 | const { viewBox, camera } = data 617 | if (viewBox.width > 0) { 618 | camera.x += (viewBox.width - frame.width) / 2 619 | camera.y += (viewBox.height - frame.height) / 2 620 | viewBox.document.x = camera.x 621 | viewBox.document.y = camera.y 622 | viewBox.document.width = viewBox.width / camera.zoom 623 | viewBox.document.height = viewBox.height / camera.zoom 624 | } 625 | }, 626 | 627 | // Viewbox ------------------------ 628 | updateViewBox(data, frame: IFrame) { 629 | const { viewBox, camera } = data 630 | viewBox.x = frame.x 631 | viewBox.y = frame.y 632 | viewBox.width = frame.width 633 | viewBox.height = frame.height 634 | viewBox.document.x = camera.x 635 | viewBox.document.y = camera.y 636 | viewBox.document.width = viewBox.width / camera.zoom 637 | viewBox.document.height = viewBox.height / camera.zoom 638 | }, 639 | updateViewBoxOnScroll(data, point: IPoint) { 640 | const { viewBox } = data 641 | viewBox.x += viewBox.scrollX - point.x 642 | viewBox.y += viewBox.scrollY - point.y 643 | viewBox.scrollX = point.x 644 | viewBox.scrollY = point.y 645 | }, 646 | 647 | // Selection Brush ---------------- 648 | startBrush(data) { 649 | const { boxes, initial } = steady 650 | const { pointer, viewBox, camera } = data 651 | const { x, y } = viewBoxToCamera(pointer, viewBox, camera) 652 | steady.brush = { 653 | x0: initial.pointer.x, 654 | y0: initial.pointer.y, 655 | x1: x, 656 | y1: y, 657 | } 658 | selecter = getBoxSelecter(Object.values(boxes), { x, y }) 659 | }, 660 | startBrushWithWorker(data) { 661 | const { boxes, initial } = steady 662 | const { pointer, viewBox, camera } = data 663 | const { x, y } = viewBoxToCamera(pointer, viewBox, camera) 664 | steady.brush = { 665 | x0: initial.pointer.x, 666 | y0: initial.pointer.y, 667 | x1: x, 668 | y1: y, 669 | } 670 | 671 | getFromWorker("selecter", { 672 | origin: { x, y }, 673 | }) 674 | }, 675 | moveBrush(data) { 676 | const { brush } = steady 677 | const { pointer, viewBox, camera } = data 678 | if (!brush) return 679 | const point = viewBoxToCamera(pointer, viewBox, camera) 680 | brush.x1 = point.x 681 | brush.y1 = point.y 682 | }, 683 | completeBrush(data) { 684 | selecter = undefined 685 | steady.brush = undefined 686 | }, 687 | 688 | // Selection ---------------------- 689 | selectBox(data, payload = {}) { 690 | const { id } = payload 691 | data.selectedBoxIds = [id] 692 | }, 693 | setSelectedIdsFromWorker() { 694 | getFromWorker("selected", pointerState.data.document).then((r) => { 695 | if (r.length !== state.data.selectedBoxIds.length) { 696 | state.send("FORCED_IDS", r) 697 | } 698 | }) 699 | }, 700 | setSelectedIds(data, _, selectedBoxIds: string[]) { 701 | data.selectedBoxIds = selectedBoxIds 702 | }, 703 | clearSelection(data) { 704 | data.selectedBoxIds = [] 705 | data.selectedArrowIds = [] 706 | steady.bounds = undefined 707 | }, 708 | setInitialSelectedIds(data) { 709 | steady.initial.selected.boxIds = [...data.selectedBoxIds] 710 | }, 711 | 712 | // Boxes -------------------------- 713 | moveDraggingBoxes(data) { 714 | const { pointer } = data 715 | 716 | for (let id of data.selectedBoxIds) { 717 | const box = steady.boxes[id] 718 | box.x += pointer.dx 719 | box.y += pointer.dy 720 | } 721 | }, 722 | 723 | // Bounds ------------------------- 724 | moveBounds(data) { 725 | const { bounds } = steady 726 | const { pointer } = data 727 | if (!bounds) return 728 | bounds.x += pointer.dx 729 | bounds.y += pointer.dy 730 | bounds.maxX = bounds.x + bounds.width 731 | bounds.maxY = bounds.y + bounds.height 732 | }, 733 | updateBounds(data) { 734 | const { selectedBoxIds } = data 735 | if (selectedBoxIds.length === 0) steady.bounds = undefined 736 | steady.bounds = getBoundingBox( 737 | data.selectedBoxIds.map((id) => steady.boxes[id]) 738 | ) 739 | }, 740 | setEdgeResizer(data, edge: number) { 741 | const { boxes } = steady 742 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 743 | steady.bounds = getBoundingBox(selectedBoxes) 744 | resizer = BoxTransforms.getEdgeResizer(selectedBoxes, steady.bounds, edge) 745 | }, 746 | setCornerResizer(data, corner: number) { 747 | const { boxes } = steady 748 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 749 | steady.bounds = getBoundingBox(selectedBoxes) 750 | resizer = BoxTransforms.getCornerResizer( 751 | selectedBoxes, 752 | steady.bounds, 753 | corner 754 | ) 755 | }, 756 | resizeBounds(data) { 757 | const { bounds, boxes } = steady 758 | const { pointer, viewBox, camera, selectedBoxIds } = data 759 | const selectedBoxes = selectedBoxIds.map((id) => boxes[id]) 760 | if (!bounds) return 761 | const point = viewBoxToCamera(pointer, viewBox, camera) 762 | resizer && resizer(point, selectedBoxes, bounds) 763 | }, 764 | 765 | // Undo / Redo -------------------- 766 | saveUndoState(data) { 767 | const { boxes, arrows } = steady 768 | const { selectedBoxIds, selectedArrowIds } = data 769 | 770 | getFromWorker("updateTree", { 771 | boxes: Object.values(boxes), 772 | }) 773 | 774 | const current = JSON.stringify({ 775 | boxes, 776 | arrows, 777 | selectedBoxIds, 778 | selectedArrowIds, 779 | }) 780 | redos.length = 0 781 | undos.push(current) 782 | saveToDatabase(current) 783 | }, 784 | loadUndoState(data) { 785 | const { boxes, arrows } = steady 786 | const { selectedBoxIds, selectedArrowIds } = data 787 | const current = JSON.stringify({ 788 | boxes, 789 | arrows, 790 | selectedBoxIds, 791 | selectedArrowIds, 792 | }) 793 | redos.push(JSON.stringify(current)) 794 | const undo = undos.pop() 795 | if (!undo) return 796 | 797 | const json = JSON.parse(undo) 798 | Object.assign(data, json) 799 | saveToDatabase(JSON.stringify(undo)) 800 | }, 801 | loadRedoState(data) { 802 | const redo = undos.pop() 803 | if (!redo) return 804 | 805 | const json = JSON.parse(redo) 806 | Object.assign(data, json) 807 | saveToDatabase(JSON.stringify(redo)) 808 | }, 809 | saveToDatabase(data) { 810 | const { boxes, arrows } = steady 811 | const { selectedBoxIds, selectedArrowIds } = data 812 | const current = { 813 | boxes, 814 | arrows, 815 | selectedBoxIds, 816 | selectedArrowIds, 817 | } 818 | saveToDatabase(JSON.stringify(current)) 819 | }, 820 | // Boxes -------------------------- 821 | setInitialSnapshot(data) { 822 | const { boxes } = steady 823 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 824 | 825 | if (selectedBoxes.length === 0) { 826 | steady.initial.boxes = {} 827 | steady.bounds = undefined 828 | } 829 | 830 | const bounds = getBoundingBox(selectedBoxes) 831 | 832 | let initialBoxes = {} 833 | 834 | for (let box of selectedBoxes) { 835 | initialBoxes[box.id] = { 836 | id: box.id, 837 | x: box.x, 838 | y: box.y, 839 | width: box.width, 840 | height: box.height, 841 | nx: (box.x - bounds.x) / bounds.width, 842 | ny: (box.y - bounds.y) / bounds.height, 843 | nmx: (box.x + box.width - bounds.x) / bounds.width, 844 | nmy: (box.y + box.height - bounds.y) / bounds.height, 845 | nw: box.width / bounds.width, 846 | nh: box.height / bounds.height, 847 | } 848 | } 849 | 850 | steady.initial.boxes = initialBoxes 851 | steady.bounds = bounds 852 | }, 853 | alignSelectedBoxesLeft(data) { 854 | const { boxes } = steady 855 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 856 | BoxTransforms.alignBoxesLeft(selectedBoxes) 857 | }, 858 | alignSelectedBoxesRight(data) { 859 | const { boxes } = steady 860 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 861 | BoxTransforms.alignBoxesRight(selectedBoxes) 862 | }, 863 | alignSelectedBoxesTop(data) { 864 | const { boxes } = steady 865 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 866 | BoxTransforms.alignBoxesTop(selectedBoxes) 867 | }, 868 | alignSelectedBoxesBottom(data) { 869 | const { boxes } = steady 870 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 871 | BoxTransforms.alignBoxesBottom(selectedBoxes) 872 | }, 873 | alignSelectedBoxesCenterX(data) { 874 | const { boxes } = steady 875 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 876 | BoxTransforms.alignBoxesCenterX(selectedBoxes) 877 | }, 878 | alignSelectedBoxesCenterY(data) { 879 | const { boxes } = steady 880 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 881 | BoxTransforms.alignBoxesCenterY(selectedBoxes) 882 | }, 883 | distributeSelectedBoxesX(data) { 884 | const { boxes } = steady 885 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 886 | BoxTransforms.distributeBoxesX(selectedBoxes) 887 | }, 888 | distributeSelectedBoxesY(data) { 889 | const { boxes } = steady 890 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 891 | BoxTransforms.distributeBoxesY(selectedBoxes) 892 | }, 893 | stretchSelectedBoxesX(data) { 894 | const { boxes } = steady 895 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 896 | BoxTransforms.stretchBoxesX(selectedBoxes) 897 | }, 898 | stretchSelectedBoxesY(data) { 899 | const { boxes } = steady 900 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 901 | BoxTransforms.stretchBoxesY(selectedBoxes) 902 | }, 903 | deleteSelected(data) { 904 | const { arrows, boxes } = steady 905 | for (let id of data.selectedBoxIds) { 906 | for (let arrow of Object.values(arrows)) { 907 | if (arrow.to === id || arrow.from === id) { 908 | delete arrows[arrow.id] 909 | } 910 | } 911 | delete boxes[id] 912 | } 913 | data.selectedBoxIds.length = 0 914 | }, 915 | updateResizingBoxesToFreeRatio() {}, 916 | updateResizingBoxesToLockedRatio() {}, 917 | updateDraggingBoxesToFreeAxes() {}, 918 | updateDraggingBoxesToLockedAxes() {}, 919 | restoreInitialBoxes() {}, 920 | completeSelectedBoxes() {}, 921 | // Drawing Arrow 922 | createDrawingArrow() {}, 923 | setDrawingArrowTarget() {}, 924 | completeDrawingArrow() {}, 925 | clearDrawingArrow() {}, 926 | // Arrows 927 | updateSelectedArrows() {}, 928 | flipSelectedArrows() {}, 929 | invertSelectedArrows() {}, 930 | // Arrows to Boxes 931 | oxes() {}, 932 | flipArrowsToSelectedBoxes() {}, 933 | invertArrowsToSelectedBoxes() {}, 934 | // Drawing Box 935 | setBoxOrigin(data) { 936 | const { pointer, viewBox, camera } = data 937 | steady.initial.pointer = viewBoxToCamera(pointer, viewBox, camera) 938 | }, 939 | createDrawingBox(data) { 940 | const { boxes, spawning, initial } = steady 941 | const { pointer } = data 942 | spawning.boxes = { 943 | drawingBox: { 944 | id: getId(), 945 | x: Math.min(pointer.x, initial.pointer.x), 946 | y: Math.min(pointer.y, initial.pointer.y), 947 | width: Math.abs(pointer.x - initial.pointer.x), 948 | height: Math.abs(pointer.y - initial.pointer.y), 949 | label: "", 950 | color: "#FFF", 951 | z: Object.keys(boxes).length + 1, 952 | }, 953 | } 954 | }, 955 | updateDrawingBox(data) { 956 | const { spawning, initial } = steady 957 | const { pointer, viewBox, camera } = data 958 | const box = spawning.boxes.drawingBox 959 | if (!box) return 960 | const { x, y } = viewBoxToCamera(pointer, viewBox, camera) 961 | box.x = Math.min(x, initial.pointer.x) 962 | box.y = Math.min(y, initial.pointer.y) 963 | box.width = Math.abs(x - initial.pointer.x) 964 | box.height = Math.abs(y - initial.pointer.y) 965 | }, 966 | completeDrawingBox(data) { 967 | const { boxes, spawning } = steady 968 | const box = spawning.boxes.drawingBox 969 | if (!box) return 970 | boxes[box.id] = box 971 | spawning.boxes = {} 972 | data.selectedBoxIds = [box.id] 973 | }, 974 | clearDrawingBox() {}, 975 | // Boxes 976 | 977 | // Clones 978 | clearDraggingBoxesClones() {}, 979 | createDraggingBoxesClones() {}, 980 | completeBoxesFromClones() {}, 981 | // Debugging 982 | resetBoxes(data, count) { 983 | const boxes = Array.from(Array(parseInt(count))).map((_, i) => ({ 984 | id: "box_a" + i, 985 | x: -1500 + Math.random() * 3000, 986 | y: -1500 + Math.random() * 3000, 987 | width: 32 + Math.random() * 64, 988 | height: 32 + Math.random() * 64, 989 | label: "", 990 | color: "#FFF", 991 | z: i, 992 | })) 993 | 994 | const arrows = boxes.map((boxA, i) => { 995 | let boxB = boxes[i === boxes.length - 1 ? 0 : i + 1] 996 | 997 | return { 998 | id: "arrow_b" + i, 999 | type: IArrowType.BoxToBox, 1000 | from: boxA.id, 1001 | to: boxB.id, 1002 | flip: false, 1003 | label: "", 1004 | } 1005 | }) 1006 | 1007 | steady.boxes = boxes.reduce((acc, cur) => { 1008 | acc[cur.id] = cur 1009 | return acc 1010 | }, {}) 1011 | 1012 | steady.arrows = arrows.reduce((acc, cur) => { 1013 | acc[cur.id] = cur 1014 | return acc 1015 | }, {}) 1016 | 1017 | data.selectedBoxIds = [] 1018 | data.selectedArrowIds = [] 1019 | 1020 | getFromWorker("updateTree", { 1021 | boxes: Object.values(boxes), 1022 | }) 1023 | }, 1024 | }, 1025 | asyncs: { 1026 | async stretchSelectedBoxesX(data) { 1027 | const { boxes } = steady 1028 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 1029 | const next = await getFromWorker("stretchBoxesX", selectedBoxes) 1030 | 1031 | for (let box of next) { 1032 | steady.boxes[box.id] = box 1033 | } 1034 | }, 1035 | async stretchSelectedBoxesY(data) { 1036 | const { boxes } = steady 1037 | const selectedBoxes = data.selectedBoxIds.map((id) => boxes[id]) 1038 | const next = await getFromWorker("stretchBoxesY", selectedBoxes) 1039 | 1040 | for (let box of next) { 1041 | steady.boxes[box.id] = box 1042 | } 1043 | }, 1044 | }, 1045 | values: { 1046 | undosLength() { 1047 | return undos.length 1048 | }, 1049 | redosLength() { 1050 | return redos.length 1051 | }, 1052 | boundingBox(data) {}, 1053 | }, 1054 | }) 1055 | 1056 | export default state 1057 | 1058 | // state.onUpdate(update => console.log(state.active)) 1059 | -------------------------------------------------------------------------------- /components/state/pure.tsx: -------------------------------------------------------------------------------- 1 | interface Point { 2 | x: number 3 | y: number 4 | } 5 | 6 | interface Frame extends Point { 7 | width: number 8 | height: number 9 | } 10 | 11 | interface Bounds extends Frame { 12 | maxX: number 13 | maxY: number 14 | } 15 | 16 | interface Box extends Frame { 17 | id: string 18 | } 19 | 20 | interface BoxSnapshot extends Box { 21 | nx: number 22 | ny: number 23 | nmx: number 24 | nmy: number 25 | nw: number 26 | nh: number 27 | } 28 | 29 | function getBoundingBox(boxes: Box[]): Bounds { 30 | if (boxes.length === 0) { 31 | return { 32 | x: 0, 33 | y: 0, 34 | maxX: 0, 35 | maxY: 0, 36 | width: 0, 37 | height: 0, 38 | } 39 | } 40 | 41 | const first = boxes[0] 42 | 43 | let x = first.x 44 | let maxX = first.x + first.width 45 | let y = first.y 46 | let maxY = first.y + first.height 47 | 48 | for (let box of boxes) { 49 | x = Math.min(x, box.x) 50 | maxX = Math.max(maxX, box.x + box.width) 51 | y = Math.min(y, box.y) 52 | maxY = Math.max(maxY, box.y + box.height) 53 | } 54 | 55 | return { 56 | x, 57 | y, 58 | maxX, 59 | maxY, 60 | width: maxX - x, 61 | height: maxY - y, 62 | } 63 | } 64 | 65 | function getSnapshots( 66 | boxes: Box[], 67 | bounds: Bounds 68 | ): Record { 69 | const acc = {} as Record 70 | 71 | for (let box of boxes) { 72 | acc[box.id] = { 73 | ...box, 74 | nx: (box.x - bounds.x) / bounds.width, 75 | ny: (box.y - bounds.y) / bounds.height, 76 | nmx: (box.x + box.width - bounds.x) / bounds.width, 77 | nmy: (box.y + box.height - bounds.y) / bounds.height, 78 | nw: box.width / bounds.width, 79 | nh: box.height / bounds.height, 80 | } 81 | } 82 | 83 | return acc 84 | } 85 | 86 | export function getEdgeResizer(boxes: Box[], edge: number) { 87 | const initial = getBoundingBox(boxes) 88 | const snapshots = getSnapshots(boxes, initial) 89 | const mboxes = [...boxes] 90 | 91 | let { x: x0, y: y0, maxX: x1, maxY: y1 } = initial 92 | let { x: mx, y: my, width: mw, height: mh } = initial 93 | 94 | return function edgeResize({ x, y }: Point) { 95 | if (edge === 0 || edge === 2) { 96 | edge === 0 ? (y0 = y) : (y1 = y) 97 | my = y0 < y1 ? y0 : y1 98 | mh = Math.abs(y1 - y0) 99 | for (let box of boxes) { 100 | const { ny, nmy, nh } = snapshots[box.id] 101 | box.y = my + (y1 < y0 ? nmy : ny) * mh 102 | box.height = nh * mh 103 | } 104 | } else { 105 | edge === 1 ? (x1 = x) : (x0 = x) 106 | mx = x0 < x1 ? x0 : x1 107 | mw = Math.abs(x1 - x0) 108 | for (let box of mboxes) { 109 | const { nx, nmx, nw } = snapshots[box.id] 110 | box.x = mx + (x1 < x0 ? nmx : nx) * mw 111 | box.width = nw * mw 112 | } 113 | } 114 | 115 | return [ 116 | mboxes, 117 | { 118 | x: mx, 119 | y: my, 120 | w: mw, 121 | h: mh, 122 | maxX: mx + mw, 123 | maxY: my + mh, 124 | }, 125 | ] as const 126 | } 127 | } 128 | 129 | /** 130 | * Returns a function that can be used to calculate corner resize transforms. 131 | * @param boxes An array of the boxes being resized. 132 | * @param corner A number representing the corner being dragged. Top Left: 0, Top Right: 1, Bottom Right: 2, Bottom Left: 3. 133 | * @example 134 | * const resizer = getCornerResizer(selectedBoxes, 3) 135 | * resizer(selectedBoxes, ) 136 | */ 137 | export function getCornerResizer(boxes: Box[], corner: number) { 138 | const initial = getBoundingBox(boxes) 139 | const snapshots = getSnapshots(boxes, initial) 140 | const mboxes = [...boxes] 141 | 142 | let { x: _x0, y: _y0, maxX: _x1, maxY: _y1 } = initial 143 | let { x: mx, y: my, width: mw, height: mh } = initial 144 | 145 | function cornerResizer(point: Point) { 146 | corner < 2 ? (_y0 = point.y) : (_y1 = point.y) 147 | my = _y0 < _y1 ? _y0 : _y1 148 | mh = Math.abs(_y1 - _y0) 149 | 150 | corner === 1 || corner === 2 ? (_x1 = point.x) : (_x0 = point.x) 151 | mx = _x0 < _x1 ? _x0 : _x1 152 | mw = Math.abs(_x1 - _x0) 153 | 154 | for (let box of mboxes) { 155 | const { nx, nmx, nw, ny, nmy, nh } = snapshots[box.id] 156 | box.x = mx + (_x1 < _x0 ? nmx : nx) * mw 157 | box.y = my + (_y1 < _y0 ? nmy : ny) * mh 158 | box.width = nw * mw 159 | box.height = nh * mh 160 | } 161 | 162 | return [ 163 | mboxes, 164 | { 165 | x: mx, 166 | y: my, 167 | w: mw, 168 | h: mh, 169 | maxX: mx + mw, 170 | maxY: my + mh, 171 | }, 172 | ] as const 173 | } 174 | 175 | return cornerResizer 176 | } 177 | -------------------------------------------------------------------------------- /components/theme.tsx: -------------------------------------------------------------------------------- 1 | import { createStyled } from "@stitches/react" 2 | 3 | const { css, styled } = createStyled({ 4 | tokens: { 5 | colors: { 6 | $background: "rgba(245, 245, 245, 1.000)", 7 | $text: "rgba(0, 0, 0, 1.000)", 8 | $accent: "rgba(77, 223, 234, 1.000)", 9 | $select: "rgba(77, 223, 234, 1.000)", 10 | $muted: "rgba(220, 220, 224, 1.000)", 11 | $icon: "#efefef", 12 | $canvas: "#efefef", 13 | $toolbar: "rgba(240, 240, 240, .5)", 14 | }, 15 | space: { 16 | $0: "4px", 17 | $1: "8px", 18 | $2: "12px", 19 | $3: "16px", 20 | $4: "24px", 21 | $6: "32px", 22 | $7: "40px", 23 | $8: "64px", 24 | $9: "96px", 25 | $10: "128px", 26 | }, 27 | radii: { 28 | $0: "2px", 29 | $1: "4px", 30 | }, 31 | fontSizes: { 32 | $0: "10px", 33 | $1: "12px", 34 | $2: "14px", 35 | }, 36 | fonts: { 37 | $body: "Helvetica Neue", 38 | }, 39 | }, 40 | utils: { 41 | m: () => (value: number | string) => ({ 42 | marginTop: value, 43 | marginBottom: value, 44 | marginLeft: value, 45 | marginRight: value, 46 | }), 47 | mt: () => (value: number | string) => ({ 48 | marginTop: value, 49 | }), 50 | mr: () => (value: number | string) => ({ 51 | marginRight: value, 52 | }), 53 | mb: () => (value: number | string) => ({ 54 | marginBottom: value, 55 | }), 56 | ml: () => (value: number | string) => ({ 57 | marginLeft: value, 58 | }), 59 | mx: () => (value: number | string) => ({ 60 | marginLeft: value, 61 | marginRight: value, 62 | }), 63 | my: () => (value: number | string) => ({ 64 | marginTop: value, 65 | marginBottom: value, 66 | }), 67 | p: () => (value: number | string) => ({ 68 | paddingTop: value, 69 | paddingBottom: value, 70 | paddingLeft: value, 71 | paddingRight: value, 72 | padding: value, 73 | }), 74 | pt: () => (value: number | string) => ({ 75 | paddingTop: value, 76 | }), 77 | pr: () => (value: number | string) => ({ 78 | paddingRight: value, 79 | }), 80 | pb: () => (value: number | string) => ({ 81 | paddingBottom: value, 82 | }), 83 | pl: () => (value: number | string) => ({ 84 | paddingLeft: value, 85 | }), 86 | px: () => (value: number | string) => ({ 87 | paddingLeft: value, 88 | paddingRight: value, 89 | }), 90 | py: () => (value: number | string) => ({ 91 | paddingTop: value, 92 | paddingBottom: value, 93 | }), 94 | size: () => (value: number | string) => ({ 95 | width: value, 96 | height: value, 97 | }), 98 | bg: () => (value: string) => ({ 99 | backgroundColor: value, 100 | }), 101 | fadeBg: () => (value: number) => ({ 102 | transition: `background-color ${value}s`, 103 | }), 104 | }, 105 | }) 106 | 107 | css.global({ 108 | html: { 109 | margin: 0, 110 | padding: 0, 111 | fontFamily: "$body", 112 | fontSize: "$2", 113 | }, 114 | body: { 115 | overscrollBehavior: "none", 116 | }, 117 | }) 118 | 119 | export { css, styled } 120 | -------------------------------------------------------------------------------- /components/toolbar/icon-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import state from "../state" 3 | import { ButtonWrapper, ShortcutHint, Button } from "./styled" 4 | import * as Icons from "./icons/svgr" 5 | 6 | type IconButtonProps = { 7 | event: string 8 | isActive?: boolean 9 | src: string 10 | shortcut?: string 11 | } & React.HTMLProps 12 | 13 | export default function IconButton({ 14 | event = "", 15 | isActive = false, 16 | src, 17 | shortcut, 18 | children, 19 | ...props 20 | }: IconButtonProps) { 21 | const Icon = Icons[src] 22 | 23 | return ( 24 | 25 | 33 | {shortcut && {shortcut}} 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /components/toolbar/icons/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/icons/bottom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/icons/box.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/toolbar/icons/center-x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/icons/center-y.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/icons/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | svgProps: { height: 32, width: 32 }, 3 | outDir: "svgr", 4 | icon: true, 5 | typescript: true, 6 | } 7 | -------------------------------------------------------------------------------- /components/toolbar/icons/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/icons/distribute-x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/icons/distribute-y.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/icons/flip-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /components/toolbar/icons/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 16 | 20 | 29 | React App 30 | 31 | 32 | 33 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /components/toolbar/icons/invert-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /components/toolbar/icons/left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/icons/redo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/icons/right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/icons/select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/toolbar/icons/stretch-x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /components/toolbar/icons/stretch-y.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/Arrow.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgArrow(props: React.SVGProps) { 4 | return ( 5 | 6 | 11 | 12 | 17 | 18 | ) 19 | } 20 | 21 | export default SvgArrow 22 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/Bottom.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgBottom(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 13 | 14 | ) 15 | } 16 | 17 | export default SvgBottom 18 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/Box.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgBox(props: React.SVGProps) { 4 | return ( 5 | 6 | 12 | 13 | ) 14 | } 15 | 16 | export default SvgBox 17 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/CenterX.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgCenterX(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 13 | 14 | ) 15 | } 16 | 17 | export default SvgCenterX 18 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/CenterY.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgCenterY(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 13 | 14 | ) 15 | } 16 | 17 | export default SvgCenterY 18 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/Delete.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgDelete(props: React.SVGProps) { 4 | return ( 5 | 6 | 12 | 16 | 20 | 21 | ) 22 | } 23 | 24 | export default SvgDelete 25 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/DistributeX.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgDistributeX(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 13 | 14 | ) 15 | } 16 | 17 | export default SvgDistributeX 18 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/DistributeY.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgDistributeY(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 13 | 14 | ) 15 | } 16 | 17 | export default SvgDistributeY 18 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/FlipArrow.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgFlipArrow(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 16 | 22 | 27 | 32 | 33 | 34 | ) 35 | } 36 | 37 | export default SvgFlipArrow 38 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/InvertArrow.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgInvertArrow(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 8 | 9 | 14 | 19 | 24 | 25 | ) 26 | } 27 | 28 | export default SvgInvertArrow 29 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/Left.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgLeft(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 13 | 14 | ) 15 | } 16 | 17 | export default SvgLeft 18 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/Redo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgRedo(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | ) 11 | } 12 | 13 | export default SvgRedo 14 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/Right.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgRight(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 13 | 14 | ) 15 | } 16 | 17 | export default SvgRight 18 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/Select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgSelect(props: React.SVGProps) { 4 | return ( 5 | 6 | 11 | 12 | ) 13 | } 14 | 15 | export default SvgSelect 16 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/StretchX.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgStretchX(props: React.SVGProps) { 4 | return ( 5 | 6 | 11 | 17 | 18 | ) 19 | } 20 | 21 | export default SvgStretchX 22 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/StretchY.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgStretchY(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 13 | 14 | ) 15 | } 16 | 17 | export default SvgStretchY 18 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/Top.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgTop(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 13 | 14 | ) 15 | } 16 | 17 | export default SvgTop 18 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/Undo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | function SvgUndo(props: React.SVGProps) { 4 | return ( 5 | 6 | 7 | 8 | 14 | 15 | ) 16 | } 17 | 18 | export default SvgUndo 19 | -------------------------------------------------------------------------------- /components/toolbar/icons/svgr/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as Arrow } from "./Arrow" 2 | export { default as Bottom } from "./Bottom" 3 | export { default as Box } from "./Box" 4 | export { default as CenterX } from "./CenterX" 5 | export { default as CenterY } from "./CenterY" 6 | export { default as Delete } from "./Delete" 7 | export { default as DistributeX } from "./DistributeX" 8 | export { default as DistributeY } from "./DistributeY" 9 | export { default as FlipArrow } from "./FlipArrow" 10 | export { default as InvertArrow } from "./InvertArrow" 11 | export { default as Left } from "./Left" 12 | export { default as Redo } from "./Redo" 13 | export { default as Right } from "./Right" 14 | export { default as Select } from "./Select" 15 | export { default as StretchX } from "./StretchX" 16 | export { default as StretchY } from "./StretchY" 17 | export { default as Top } from "./Top" 18 | export { default as Undo } from "./Undo" 19 | -------------------------------------------------------------------------------- /components/toolbar/icons/top.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/icons/undo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/toolbar/styled.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "../theme" 2 | 3 | // Toolbar 4 | 5 | export const ToolbarWrapper = styled.div({ 6 | display: "flex", 7 | justifyContent: "space-between", 8 | alignItems: "flex-start", 9 | position: "absolute", 10 | top: 0, 11 | left: 0, 12 | height: 40, 13 | width: "100vw", 14 | maxWidth: "100vw", 15 | flexWrap: "wrap", 16 | borderBottom: "1px solid $muted", 17 | backgroundColor: "$toolbar", 18 | backdropFilter: "blur(4px)", 19 | }) 20 | 21 | export const ButtonGroup = styled.div({ 22 | display: "flex", 23 | userSelect: "none", 24 | minWidth: 0, 25 | padding: "0 $1", 26 | gap: "$0", 27 | flexWrap: "wrap", 28 | }) 29 | 30 | export const Divider = styled.span({ 31 | padding: "$2", 32 | color: "rgba(0,0,0,.5)", 33 | }) 34 | 35 | // Buttons 36 | 37 | export const ButtonWrapper = styled.div({ 38 | display: "flex", 39 | flexDirection: "column", 40 | alignItems: "center", 41 | "& > *:nth-child(2)": { 42 | visibility: "hidden", 43 | }, 44 | "&:hover > *:nth-child(2)": { 45 | visibility: "visible", 46 | }, 47 | }) 48 | 49 | export const Button = styled.button({ 50 | height: 40, 51 | width: 32, 52 | p: 0, 53 | outline: "none", 54 | cursor: "pointer", 55 | display: "flex", 56 | alignItems: "center", 57 | justifyContent: "center", 58 | fontSize: 28, 59 | // borderWidth: 2, 60 | // borderStyle: "outset", 61 | // borderColor: "$muted", 62 | border: "none", 63 | borderRadius: "$1", 64 | backgroundColor: "transparent", 65 | transition: "opacity, filter .16s", 66 | gridRow: 1, 67 | color: "$icon", 68 | "&:disabled": { 69 | opacity: 0.5, 70 | }, 71 | "&:hover": { 72 | filter: "brightness(.95)", 73 | bg: "$background", 74 | }, 75 | "&:active": { 76 | filter: "brightness(.9)", 77 | transform: "translate(0 1px)", 78 | }, 79 | variants: { 80 | status: { 81 | "": {}, 82 | active: { 83 | color: "$accent", 84 | borderColor: "$accent", 85 | }, 86 | }, 87 | }, 88 | }) 89 | 90 | export const ShortcutHint = styled.div({ 91 | py: "$0", 92 | px: "$1", 93 | mt: "$0", 94 | borderRadius: "$1", 95 | fontSize: "$0", 96 | fontWeight: "bold", 97 | backgroundColor: "$muted", 98 | position: "absolute", 99 | top: "calc(100% + 2px)", 100 | }) 101 | 102 | export const Spacer = styled.div({ 103 | flexGrow: 2, 104 | }) 105 | -------------------------------------------------------------------------------- /components/toolbar/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import state from "../state" 3 | import { useStateDesigner } from "@state-designer/react" 4 | import { ToolbarWrapper, ButtonGroup, Divider } from "./styled" 5 | import IconButton from "./icon-button" 6 | 7 | export default function Toolbar() { 8 | const local = useStateDesigner(state) 9 | 10 | const { selectedBoxIds = [], selectedArrowIds = [] } = local.data 11 | 12 | const hasSelection = selectedBoxIds.length + selectedArrowIds.length > 0 13 | const hasSelectedBox = selectedBoxIds.length > 0 14 | const hasSelectedBoxes = selectedBoxIds.length > 1 15 | const hasManySelectedBoxes = selectedBoxIds.length > 2 16 | 17 | return ( 18 | e.stopPropagation()}> 19 | 20 | 26 | state.send("SELECTED_BOX_TOOL")} 30 | event="SELECTED_BOX_TOOL" 31 | shortcut="F" 32 | /> 33 | 39 | 40 | 46 | 52 | 53 | 59 | 65 | 71 | 77 | 83 | 89 | 95 | 101 | 102 | 108 | 114 | 115 | 121 | 122 | 123 | 129 | 135 | 136 | 137 | ) 138 | } 139 | -------------------------------------------------------------------------------- /components/utils.tsx: -------------------------------------------------------------------------------- 1 | import { getBoxToBoxArrow, ArrowOptions } from "perfect-arrows" 2 | import uniqueId from "lodash/uniqueId" 3 | import { IPoint, IBounds, IFrame, IBox, IArrow } from "../types" 4 | import state from "./state/index" 5 | 6 | export let scale = 1 7 | export const pressedKeys = {} as Record 8 | export const pointer = { x: 0, y: 0 } 9 | export const origin = { x: 0, y: 0 } 10 | export const cameraOrigin = { x: 0, y: 0 } 11 | export const camera = { x: 0, y: 0, cx: 0, cy: 0, width: 0, height: 0 } 12 | 13 | let dpr = 2 14 | 15 | export function viewBoxToCamera( 16 | point: IPoint, 17 | viewBox: IFrame, 18 | camera: { x: number; y: number; zoom: number } 19 | ) { 20 | return { 21 | x: (camera.x + point.x - viewBox.x) / camera.zoom, 22 | y: (camera.y + point.y - viewBox.y) / camera.zoom, 23 | } 24 | } 25 | 26 | export function getBoundingBox(boxes: IBox[]): IBounds { 27 | if (boxes.length === 0) { 28 | return { 29 | x: 0, 30 | y: 0, 31 | maxX: 0, 32 | maxY: 0, 33 | width: 0, 34 | height: 0, 35 | } 36 | } 37 | 38 | const first = boxes[0] 39 | 40 | let x = first.x 41 | let maxX = first.x + first.width 42 | let y = first.y 43 | let maxY = first.y + first.height 44 | 45 | for (let box of boxes) { 46 | x = Math.min(x, box.x) 47 | maxX = Math.max(maxX, box.x + box.width) 48 | y = Math.min(y, box.y) 49 | maxY = Math.max(maxY, box.y + box.height) 50 | } 51 | 52 | return { 53 | x, 54 | y, 55 | width: maxX - x, 56 | height: maxY - y, 57 | maxX, 58 | maxY, 59 | } 60 | } 61 | 62 | export function mapValues( 63 | obj: { [key: string]: T }, 64 | fn: (value: T, index: number) => P 65 | ): { [key: string]: P } { 66 | return Object.fromEntries( 67 | Object.entries(obj).map(([id, value], index) => [id, fn(value, index)]) 68 | ) 69 | } 70 | 71 | export function getInitialIndex() { 72 | if (typeof window === undefined || !window.localStorage) return "0" 73 | 74 | let curIndex = "1" 75 | let prevIndex: any = localStorage.getItem("__index") 76 | if (prevIndex === null) { 77 | curIndex = "1" 78 | } else { 79 | const num = parseInt(JSON.parse(prevIndex), 10) 80 | curIndex = (num + 1).toString() 81 | } 82 | 83 | localStorage.setItem("__index", JSON.stringify(curIndex)) 84 | } 85 | 86 | /** 87 | * Get an arrow between boxes. 88 | * @param a 89 | * @param b 90 | * @param options 91 | */ 92 | export function getArrow( 93 | a: IBox, 94 | b: IBox, 95 | options: Partial = {} 96 | ) { 97 | const opts = { 98 | box: 0.05, 99 | stretchMax: 1200, 100 | padEnd: 12, 101 | ...options, 102 | } 103 | return getBoxToBoxArrow( 104 | a.x, 105 | a.y, 106 | a.width, 107 | a.height, 108 | b.x, 109 | b.y, 110 | b.width, 111 | b.height, 112 | opts 113 | ) 114 | } 115 | 116 | const keyDownActions = { 117 | Escape: "CANCELLED", 118 | Alt: "ENTERED_ALT_MODE", 119 | " ": "ENTERED_SPACE_MODE", 120 | Backspace: "DELETED_SELECTED", 121 | Shift: "ENTERED_SHIFT_MODE", 122 | Control: "ENTERED_CONTROL_MODE", 123 | Meta: "ENTERED_META_MODE", 124 | f: "SELECTED_BOX_TOOL", 125 | v: "SELECTED_SELECT_TOOL", 126 | r: "INVERTED_ARROWS", 127 | t: "FLIPPED_ARROWS", 128 | a: "STARTED_PICKING_ARROW", 129 | } 130 | 131 | const keyUpActions = { 132 | Alt: "EXITED_ALT_MODE", 133 | " ": "EXITED_SPACE_MODE", 134 | Shift: "EXITED_SHIFT_MODE", 135 | Control: "EXITED_CONTROL_MODE", 136 | Meta: "EXITED_META_MODE", 137 | v: "SELECTED_SELECT_TOOL", 138 | r: "INVERTED_ARROWS", 139 | t: "FLIPPED_ARROWS", 140 | a: "STARTED_PICKING_ARROW", 141 | } 142 | 143 | export function testKeyCombo(event: string, ...keys: string[]) { 144 | if (keys.every((key) => pressedKeys[key])) state.send(event) 145 | } 146 | 147 | export function handleKeyDown(e: KeyboardEvent) { 148 | pressedKeys[e.key] = true 149 | const action = keyDownActions[e.key] 150 | if (action) state.send(action) 151 | 152 | // Handle shift here? 153 | } 154 | 155 | export function handleKeyUp(e: KeyboardEvent) { 156 | if ( 157 | pressedKeys.Option || 158 | pressedKeys.Shift || 159 | pressedKeys.Meta || 160 | pressedKeys.Control 161 | ) { 162 | testKeyCombo("ALIGNED_LEFT", "Option", "a") 163 | testKeyCombo("ALIGNED_CENTER_X", "Option", "h") 164 | testKeyCombo("ALIGNED_RIGHT", "Option", "d") 165 | testKeyCombo("ALIGNED_TOP", "Option", "w") 166 | testKeyCombo("ALIGNED_CENTER_Y", "Option", "v") 167 | testKeyCombo("ALIGNED_BOTTOM", "Option", "s") 168 | testKeyCombo("DISTRIBUTED_X", "Option", "Control", "h") 169 | testKeyCombo("DISTRIBUTED_Y", "Option", "Control", "v") 170 | testKeyCombo("STRETCHED_X", "Option", "Shift", "h") 171 | testKeyCombo("STRETCHED_Y", "Option", "Shift", "v") 172 | testKeyCombo("BROUGHT_FORWARD", "Meta", "]") 173 | testKeyCombo("SENT_BACKWARD", "Meta", "[") 174 | testKeyCombo("BROUGHT_TO_FRONT", "Meta", "Shift", "]") 175 | testKeyCombo("SENT_TO_BACK", "Meta", "Shift", "[") 176 | testKeyCombo("PASTED", "Meta", "v") 177 | testKeyCombo("COPIED", "Meta", "c") 178 | testKeyCombo("UNDO", "Meta", "z") 179 | testKeyCombo("REDO", "Meta", "Shift", "z") 180 | return 181 | } else { 182 | const action = keyUpActions[e.key] 183 | if (action) state.send(action) 184 | } 185 | 186 | pressedKeys[e.key] = false 187 | } 188 | 189 | export function handleKeyPress(e: KeyboardEvent) { 190 | if (e.key === " " && !state.isInAny("editingLabel", "editingArrowLabel")) { 191 | e.preventDefault() 192 | } 193 | } 194 | 195 | export function pointInRectangle(a: IPoint, b: IFrame, padding = 0) { 196 | const r = padding / 2 197 | return !( 198 | a.x > b.x + b.width + r || 199 | a.y > b.y + b.height + r || 200 | a.x < b.x - r || 201 | a.y < b.y - r 202 | ) 203 | } 204 | 205 | export function pointInCorner(a: IPoint, b: IFrame, padding = 4) { 206 | let cx: number, cy: number 207 | const r = padding / 2 208 | const corners = getCorners(b.x, b.y, b.width, b.height) 209 | 210 | for (let i = 0; i < corners.length; i++) { 211 | ;[cx, cy] = corners[i] 212 | if ( 213 | pointInRectangle( 214 | a, 215 | { 216 | x: cx - 4, 217 | y: cy - 4, 218 | width: 8, 219 | height: 8, 220 | }, 221 | 0 222 | ) 223 | ) 224 | return i 225 | } 226 | } 227 | 228 | export function lineToRectangle( 229 | x0: number, 230 | y0: number, 231 | x1: number, 232 | y1: number, 233 | padding = 8 234 | ) { 235 | const r = padding / 2 236 | if (x1 < x0) [x0, x1] = [x1, x0] 237 | if (y1 < y0) [y0, y1] = [y1, y0] 238 | return { 239 | x: x0 - r, 240 | y: y0 - r, 241 | width: x1 + r - (x0 - r), 242 | height: y1 + r - (y0 - r), 243 | } 244 | } 245 | 246 | export function pointInEdge(a: IPoint, b: IFrame, padding = 4) { 247 | const edges = getEdges(b.x, b.y, b.width, b.height) 248 | 249 | for (let i = 0; i < edges.length; i++) { 250 | const [[x0, y0], [x1, y1]] = edges[i] 251 | if (pointInRectangle(a, lineToRectangle(x0, y0, x1, y1), padding)) return i 252 | } 253 | } 254 | 255 | export function doBoxesCollide(a: IFrame, b: IFrame) { 256 | return !( 257 | a.x > b.x + b.width || 258 | a.y > b.y + b.height || 259 | a.x + a.width < b.x || 260 | a.y + a.height < b.y 261 | ) 262 | } 263 | 264 | export function getBox( 265 | x: number, 266 | y: number, 267 | z: number, 268 | width: number, 269 | height: number 270 | ): IBox { 271 | return { 272 | id: "box" + uniqueId(), 273 | x, 274 | y, 275 | z, 276 | width, 277 | height, 278 | label: "", 279 | color: "#ffffff", 280 | } 281 | } 282 | 283 | export function getEdges(x: number, y: number, w: number, h: number) { 284 | return [ 285 | [ 286 | [x, y], 287 | [x + w, y], 288 | ], 289 | [ 290 | [x + w, y], 291 | [x + w, y + h], 292 | ], 293 | [ 294 | [x + w, y + h], 295 | [x, y + h], 296 | ], 297 | [ 298 | [x, y + h], 299 | [x, y], 300 | ], 301 | ] 302 | } 303 | 304 | export function getCorners(x: number, y: number, w: number, h: number) { 305 | return [ 306 | [x, y], 307 | [x + w, y], 308 | [x + w, y + h], 309 | [x, y + h], 310 | ] 311 | } 312 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpack(config, options) { 3 | config.module.rules.push({ 4 | test: /\.worker\.js$/, 5 | loader: "worker-loader", 6 | // options: { inline: true }, // also works 7 | options: { 8 | name: "static/[hash].worker.js", 9 | publicPath: "/_next/", 10 | }, 11 | }) 12 | return config 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arrows-playground", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@state-designer/react": "^1.2.31", 12 | "@stitches/react": "^0.0.2", 13 | "@types/rbush": "^3.0.0", 14 | "@types/uuid": "^8.3.0", 15 | "@zeit/next-workers": "^1.0.0", 16 | "comlink": "^4.3.0", 17 | "framer-motion": "^2.7.9", 18 | "lodash": "^4.17.20", 19 | "next": "9.5.4", 20 | "perfect-arrows": "^0.3.3", 21 | "pixi.js": "^5.3.3", 22 | "rbush": "^3.0.1", 23 | "react": "^16.14.0", 24 | "react-dom": "^16.14.0", 25 | "react-no-ssr": "^1.1.0", 26 | "use-resize-observer": "^6.1.0", 27 | "uuid": "^8.3.1", 28 | "worker-loader": "^3.0.5" 29 | }, 30 | "devDependencies": { 31 | "@types/node": "^14.11.5", 32 | "typescript": "^4.0.3" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | 3 | function MyApp({ Component, pageProps }) { 4 | return 5 | } 6 | 7 | export default MyApp 8 | -------------------------------------------------------------------------------- /pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default (req, res) => { 4 | res.statusCode = 200 5 | res.json({ name: 'John Doe' }) 6 | } 7 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic" 2 | 3 | const App = dynamic(() => import("../components/app"), { 4 | ssr: false, 5 | }) 6 | 7 | export default function Home() { 8 | return 9 | } 10 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steveruizok/arrows-playground/2a7160257f9fd0176d29bb4c61490e8c46c8b208/public/favicon.ico -------------------------------------------------------------------------------- /public/service.worker.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | importScripts( 3 | "https://unpkg.com/comlink/dist/umd/comlink.js", 4 | "https://unpkg.com/rbush@3.0.1/rbush.min.js" 5 | ) 6 | 7 | const tree = new RBush() 8 | const hitTree = new RBush() 9 | 10 | function updateTree({ boxes }) { 11 | tree.clear() 12 | 13 | tree.load( 14 | boxes.map((box) => ({ 15 | id: box.id, 16 | minX: box.x, 17 | minY: box.y, 18 | maxX: box.x + box.width, 19 | maxY: box.y + box.height, 20 | })) 21 | ) 22 | 23 | return tree 24 | } 25 | 26 | const throttle = (fn, wait) => { 27 | let inThrottle, lastFn, lastTime 28 | return function () { 29 | const context = this, 30 | args = arguments 31 | if (!inThrottle) { 32 | fn.apply(context, args) 33 | lastTime = Date.now() 34 | inThrottle = true 35 | } else { 36 | clearTimeout(lastFn) 37 | lastFn = setTimeout(function () { 38 | if (Date.now() - lastTime >= wait) { 39 | fn.apply(context, args) 40 | lastTime = Date.now() 41 | } 42 | }, Math.max(wait - (Date.now() - lastTime), 0)) 43 | } 44 | } 45 | } 46 | 47 | function getBoundingBox(boxes) { 48 | if (boxes.length === 0) { 49 | return { 50 | x: 0, 51 | y: 0, 52 | maxX: 0, 53 | maxY: 0, 54 | width: 0, 55 | height: 0, 56 | } 57 | } 58 | 59 | const first = boxes[0] 60 | 61 | let x = first.minX 62 | let maxX = first.maxX 63 | let y = first.minX 64 | let maxY = first.maxY 65 | 66 | for (let box of boxes) { 67 | x = Math.min(x, box.minX) 68 | maxX = Math.max(maxX, box.maxX) 69 | y = Math.min(y, box.minY) 70 | maxY = Math.max(maxY, box.maxY) 71 | } 72 | 73 | return { 74 | x, 75 | y, 76 | width: maxX - x, 77 | height: maxY - y, 78 | maxX, 79 | maxY, 80 | } 81 | } 82 | 83 | let selected = [] 84 | let bounds = {} 85 | 86 | function getBoxSelecter({ origin }) { 87 | let x0, y0, x1, y1, t 88 | const { x: ox, y: oy } = origin 89 | 90 | return function select(point) { 91 | x0 = ox 92 | y0 = oy 93 | x1 = point.x 94 | y1 = point.y 95 | 96 | if (x1 < x0) { 97 | t = x0 98 | x0 = x1 99 | x1 = t 100 | } 101 | 102 | if (y1 < y0) { 103 | t = y0 104 | y0 = y1 105 | y1 = t 106 | } 107 | 108 | const results = tree.search({ minX: x0, minY: y0, maxX: x1, maxY: y1 }) 109 | 110 | selected = results.map((b) => b.id) 111 | bounds = getBoundingBox(results) 112 | return results 113 | } 114 | } 115 | 116 | function pointInRectangle(a, b, padding = 0) { 117 | const r = padding / 2 118 | return !( 119 | a.x > b.x + b.width + r || 120 | a.y > b.y + b.height + r || 121 | a.x < b.x - r || 122 | a.y < b.y - r 123 | ) 124 | } 125 | 126 | function getCorners(x, y, w, h) { 127 | return [ 128 | [x, y], 129 | [x + w, y], 130 | [x + w, y + h], 131 | [x, y + h], 132 | ] 133 | } 134 | 135 | function pointInCorner(a, b, padding = 4) { 136 | let cx, cy 137 | const r = padding / 2 138 | const corners = getCorners(b.x, b.y, b.width, b.height) 139 | 140 | for (let i = 0; i < corners.length; i++) { 141 | ;[cx, cy] = corners[i] 142 | if ( 143 | pointInRectangle( 144 | a, 145 | { 146 | x: cx - 4, 147 | y: cy - 4, 148 | width: 8, 149 | height: 8, 150 | }, 151 | 0 152 | ) 153 | ) 154 | return i 155 | } 156 | } 157 | 158 | function lineToRectangle(x0, y0, x1, y1, padding = 8) { 159 | const r = padding / 2 160 | if (x1 < x0) [x0, x1] = [x1, x0] 161 | if (y1 < y0) [y0, y1] = [y1, y0] 162 | return { 163 | x: x0 - r, 164 | y: y0 - r, 165 | width: x1 + r - (x0 - r), 166 | height: y1 + r - (y0 - r), 167 | } 168 | } 169 | 170 | function getEdges(x, y, w, h) { 171 | return [ 172 | [ 173 | [x, y], 174 | [x + w, y], 175 | ], 176 | [ 177 | [x + w, y], 178 | [x + w, y + h], 179 | ], 180 | [ 181 | [x + w, y + h], 182 | [x, y + h], 183 | ], 184 | [ 185 | [x, y + h], 186 | [x, y], 187 | ], 188 | ] 189 | } 190 | 191 | function pointInEdge(a, b, padding = 4) { 192 | const edges = getEdges(b.x, b.y, b.width, b.height) 193 | 194 | for (let i = 0; i < edges.length; i++) { 195 | const [[x0, y0], [x1, y1]] = edges[i] 196 | if (pointInRectangle(a, lineToRectangle(x0, y0, x1, y1), padding)) return i 197 | } 198 | } 199 | 200 | function doBoxesCollide(a, b) { 201 | return !( 202 | a.x > b.x + b.width || 203 | a.y > b.y + b.height || 204 | a.x + a.width < b.x || 205 | a.y + a.height < b.y 206 | ) 207 | } 208 | 209 | function stretchBoxesX(boxes) { 210 | const [first, ...rest] = boxes 211 | let min = first.x 212 | let max = first.x + first.width 213 | for (let box of rest) { 214 | min = Math.min(min, box.x) 215 | max = Math.max(max, box.x + box.width) 216 | } 217 | for (let box of boxes) { 218 | box.x = min 219 | box.width = max - min 220 | } 221 | 222 | return boxes 223 | } 224 | 225 | function stretchBoxesY(boxes) { 226 | const [first, ...rest] = boxes 227 | let min = first.y 228 | let max = first.y + first.height 229 | for (let box of rest) { 230 | min = Math.min(min, box.y) 231 | max = Math.max(max, box.y + box.height) 232 | } 233 | for (let box of boxes) { 234 | box.y = min 235 | box.height = max - min 236 | } 237 | 238 | return boxes 239 | } 240 | 241 | function updateHitTestTree(boxes) { 242 | hitTree.clear() 243 | 244 | hitTree.load( 245 | boxes.map((box) => ({ 246 | id: box.id, 247 | minX: box.x, 248 | minY: box.y, 249 | maxX: box.x + box.width, 250 | maxY: box.y + box.height, 251 | z: box.z, 252 | })) 253 | ) 254 | } 255 | 256 | function hitTest({ point, bounds, zoom }) { 257 | if (bounds) { 258 | // Test if point collides the (padded) bounds 259 | if (pointInRectangle(point, bounds, 16)) { 260 | const { x, y, width, height, maxX, maxY } = bounds 261 | const p = 5 / zoom 262 | const pp = p * 2 263 | 264 | const cornerBoxes = [ 265 | { x: x - p, y: y - p, width: pp, height: pp }, 266 | { x: maxX - p, y: y - p, width: pp, height: pp }, 267 | { x: maxX - p, y: maxY - p, width: pp, height: pp }, 268 | { x: x - p, y: maxY - p, width: pp, height: pp }, 269 | ] 270 | 271 | for (let i = 0; i < cornerBoxes.length; i++) { 272 | if (pointInRectangle(point, cornerBoxes[i])) { 273 | return { type: "bounds-corner", corner: i } 274 | } 275 | } 276 | 277 | const edgeBoxes = [ 278 | { x: x + p, y: y - p, width: width - pp, height: pp }, 279 | { x: maxX - p, y: y + p, width: pp, height: height - pp }, 280 | { x: x + p, y: maxY - p, width: width - pp, height: pp }, 281 | { x: x - p, y: y + p, width: pp, height: height - pp }, 282 | ] 283 | 284 | for (let i = 0; i < edgeBoxes.length; i++) { 285 | if (pointInRectangle(point, edgeBoxes[i])) { 286 | return { type: "bounds-edge", edge: i } 287 | } 288 | } 289 | // Point is in the middle of the bounds 290 | return { type: "bounds" } 291 | } 292 | } 293 | 294 | if (!point) return 295 | 296 | const hits = hitTree.search({ 297 | minX: point.x, 298 | minY: point.y, 299 | maxX: point.x + 1, 300 | maxY: point.y + 1, 301 | }) 302 | 303 | // Either we don't have bounds or we're out of bounds 304 | // for (let id in boxes) { 305 | // box = boxes[id] 306 | // // Test if point collides the (padded) box 307 | // if (pointInRectangle(point, box)) { 308 | // hits.push(box) 309 | // } 310 | // } 311 | 312 | if (hits.length > 0) { 313 | const hit = Object.values(hits).sort((a, b) => b.z - a.z)[0] 314 | return { type: "box", id: hit.id } 315 | } 316 | 317 | return { type: "canvas" } 318 | } 319 | 320 | let boxSelecter = undefined 321 | 322 | function getTransform(type, payload) { 323 | switch (type) { 324 | case "stretchBoxesX": { 325 | return stretchBoxesX(payload) 326 | } 327 | case "stretchBoxesY": { 328 | return stretchBoxesY(payload) 329 | } 330 | case "updateHitTree": { 331 | updateHitTestTree(payload) 332 | } 333 | case "hitTest": { 334 | return hitTest(payload) 335 | } 336 | case "updateTree": { 337 | return updateTree(payload) 338 | } 339 | case "selecter": { 340 | boxSelecter = getBoxSelecter(payload) 341 | const { minX, minY, maxX, maxY } = tree 342 | return { minX, minY, maxX, maxY } 343 | } 344 | case "selectedBounds": { 345 | return bounds 346 | } 347 | case "selected": { 348 | boxSelecter(payload) 349 | 350 | return selected 351 | } 352 | } 353 | } 354 | 355 | Comlink.expose(getTransform) 356 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | overscroll-behavior: none; 4 | padding: 0; 5 | margin: 0; 6 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 7 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 8 | } 9 | 10 | a { 11 | color: inherit; 12 | text-decoration: none; 13 | } 14 | 15 | * { 16 | box-sizing: border-box; 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "include": [ 22 | "next-env.d.ts", 23 | "**/*.ts", 24 | "**/*.tsx" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export interface IPoint { 2 | x: number 3 | y: number 4 | } 5 | 6 | export interface IPointer extends IPoint { 7 | dx: number 8 | dy: number 9 | } 10 | 11 | export interface ISize { 12 | width: number 13 | height: number 14 | } 15 | 16 | export interface IBrush { 17 | x0: number 18 | y0: number 19 | x1: number 20 | y1: number 21 | } 22 | 23 | export interface IFrame extends IPoint, ISize {} 24 | 25 | export interface IBounds extends IPoint, ISize { 26 | maxX: number 27 | maxY: number 28 | } 29 | 30 | export interface IBox extends IFrame { 31 | id: string 32 | label: string 33 | color: string 34 | z: number 35 | } 36 | 37 | export interface IBoxSnapshot extends IFrame { 38 | id: string 39 | nx: number 40 | ny: number 41 | nmx: number 42 | nmy: number 43 | nw: number 44 | nh: number 45 | } 46 | 47 | export enum IArrowType { 48 | BoxToBox = "box-to-box", 49 | BoxToPoint = "box-to-point", 50 | PointToBox = "point-to-box", 51 | PointToPoint = "point-to-point", 52 | } 53 | 54 | export interface IArrowBase { 55 | type: IArrowType 56 | id: string 57 | flip: boolean 58 | label: string 59 | from: string | IPoint 60 | to: string | IPoint 61 | } 62 | 63 | export interface BoxToBox extends IArrowBase { 64 | type: IArrowType.BoxToBox 65 | from: string 66 | to: string 67 | } 68 | 69 | export interface BoxToPoint extends IArrowBase { 70 | type: IArrowType.BoxToPoint 71 | from: string 72 | to: IPoint 73 | } 74 | 75 | export interface PointToBox extends IArrowBase { 76 | type: IArrowType.PointToBox 77 | from: IPoint 78 | to: string 79 | } 80 | 81 | export interface PointToPoint extends IArrowBase { 82 | type: IArrowType.PointToPoint 83 | from: IPoint 84 | to: IPoint 85 | } 86 | 87 | export type IArrow = BoxToBox | BoxToPoint | PointToBox | PointToPoint 88 | --------------------------------------------------------------------------------