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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------