You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

state.ts 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. import { createSelectorHook, createState } from "@state-designer/react"
  2. import { clamp, getCommonBounds, screenToWorld } from "utils/utils"
  3. import * as vec from "utils/vec"
  4. import {
  5. Data,
  6. PointerInfo,
  7. Shape,
  8. ShapeType,
  9. Shapes,
  10. TransformCorner,
  11. TransformEdge,
  12. } from "types"
  13. import { defaultDocument } from "./data"
  14. import shapeUtilityMap, { getShapeUtils } from "lib/shapes"
  15. import history from "state/history"
  16. import * as Sessions from "./sessions"
  17. import commands from "./commands"
  18. const initialData: Data = {
  19. isReadOnly: false,
  20. settings: {
  21. fontSize: 13,
  22. darkMode: false,
  23. },
  24. camera: {
  25. point: [0, 0],
  26. zoom: 1,
  27. },
  28. brush: undefined,
  29. pointedId: null,
  30. hoveredId: null,
  31. selectedIds: new Set([]),
  32. currentPageId: "page0",
  33. document: defaultDocument,
  34. }
  35. const state = createState({
  36. data: initialData,
  37. on: {
  38. ZOOMED_CAMERA: {
  39. do: "zoomCamera",
  40. },
  41. PANNED_CAMERA: {
  42. do: "panCamera",
  43. },
  44. SELECTED_SELECT_TOOL: { to: "selecting" },
  45. SELECTED_DOT_TOOL: { unless: "isReadOnly", to: "dot" },
  46. SELECTED_CIRCLE_TOOL: { unless: "isReadOnly", to: "circle" },
  47. SELECTED_ELLIPSE_TOOL: { unless: "isReadOnly", to: "ellipse" },
  48. SELECTED_RAY_TOOL: { unless: "isReadOnly", to: "ray" },
  49. SELECTED_LINE_TOOL: { unless: "isReadOnly", to: "line" },
  50. SELECTED_POLYLINE_TOOL: { unless: "isReadOnly", to: "polyline" },
  51. SELECTED_RECTANGLE_TOOL: { unless: "isReadOnly", to: "rectangle" },
  52. },
  53. initial: "selecting",
  54. states: {
  55. selecting: {
  56. on: {
  57. UNDO: { do: "undo" },
  58. REDO: { do: "redo" },
  59. CANCELLED: { do: "clearSelectedIds" },
  60. DELETED: { do: "deleteSelectedIds" },
  61. GENERATED_SHAPES_FROM_CODE: "setGeneratedShapes",
  62. INCREASED_CODE_FONT_SIZE: "increaseCodeFontSize",
  63. DECREASED_CODE_FONT_SIZE: "decreaseCodeFontSize",
  64. },
  65. initial: "notPointing",
  66. states: {
  67. notPointing: {
  68. on: {
  69. POINTED_CANVAS: { to: "brushSelecting" },
  70. POINTED_BOUNDS: { to: "pointingBounds" },
  71. POINTED_BOUNDS_EDGE: { to: "transformingSelection" },
  72. POINTED_BOUNDS_CORNER: { to: "transformingSelection" },
  73. MOVED_OVER_SHAPE: {
  74. if: "pointHitsShape",
  75. then: {
  76. unless: "shapeIsHovered",
  77. do: "setHoveredId",
  78. },
  79. else: { if: "shapeIsHovered", do: "clearHoveredId" },
  80. },
  81. UNHOVERED_SHAPE: "clearHoveredId",
  82. POINTED_SHAPE: [
  83. "setPointedId",
  84. {
  85. if: "isPressingShiftKey",
  86. then: {
  87. if: "isPointedShapeSelected",
  88. do: "pullPointedIdFromSelectedIds",
  89. else: {
  90. do: "pushPointedIdToSelectedIds",
  91. to: "pointingBounds",
  92. },
  93. },
  94. else: [
  95. {
  96. unless: "isPointedShapeSelected",
  97. do: ["clearSelectedIds", "pushPointedIdToSelectedIds"],
  98. },
  99. {
  100. to: "pointingBounds",
  101. },
  102. ],
  103. },
  104. ],
  105. },
  106. },
  107. pointingBounds: {
  108. on: {
  109. STOPPED_POINTING: [
  110. {
  111. unless: ["isPointingBounds", "isPressingShiftKey"],
  112. do: ["clearSelectedIds", "pushPointedIdToSelectedIds"],
  113. },
  114. { to: "notPointing" },
  115. ],
  116. MOVED_POINTER: {
  117. unless: "isReadOnly",
  118. if: "distanceImpliesDrag",
  119. to: "draggingSelection",
  120. },
  121. },
  122. },
  123. transformingSelection: {
  124. onEnter: "startTransformSession",
  125. on: {
  126. MOVED_POINTER: "updateTransformSession",
  127. PANNED_CAMERA: "updateTransformSession",
  128. STOPPED_POINTING: { do: "completeSession", to: "selecting" },
  129. CANCELLED: { do: "cancelSession", to: "selecting" },
  130. },
  131. },
  132. draggingSelection: {
  133. onEnter: "startTranslateSession",
  134. on: {
  135. MOVED_POINTER: "updateTranslateSession",
  136. PANNED_CAMERA: "updateTranslateSession",
  137. STOPPED_POINTING: { do: "completeSession", to: "selecting" },
  138. CANCELLED: { do: "cancelSession", to: "selecting" },
  139. },
  140. },
  141. brushSelecting: {
  142. onEnter: [
  143. { unless: "isPressingShiftKey", do: "clearSelectedIds" },
  144. "startBrushSession",
  145. ],
  146. on: {
  147. MOVED_POINTER: "updateBrushSession",
  148. PANNED_CAMERA: "updateBrushSession",
  149. STOPPED_POINTING: { do: "completeSession", to: "selecting" },
  150. CANCELLED: { do: "cancelSession", to: "selecting" },
  151. },
  152. },
  153. },
  154. },
  155. dot: {
  156. initial: "creating",
  157. states: {
  158. creating: {
  159. on: {
  160. POINTED_CANVAS: {
  161. do: "createDot",
  162. to: "dot.editing",
  163. },
  164. },
  165. },
  166. editing: {
  167. on: {
  168. STOPPED_POINTING: { do: "completeSession", to: "selecting" },
  169. CANCELLED: {
  170. do: ["cancelSession", "deleteSelectedIds"],
  171. to: "selecting",
  172. },
  173. },
  174. initial: "inactive",
  175. states: {
  176. inactive: {
  177. on: {
  178. MOVED_POINTER: {
  179. if: "distanceImpliesDrag",
  180. to: "dot.editing.active",
  181. },
  182. },
  183. },
  184. active: {
  185. onEnter: "startTranslateSession",
  186. on: {
  187. MOVED_POINTER: "updateTranslateSession",
  188. PANNED_CAMERA: "updateTranslateSession",
  189. },
  190. },
  191. },
  192. },
  193. },
  194. },
  195. circle: {},
  196. ellipse: {},
  197. ray: {
  198. initial: "creating",
  199. states: {
  200. creating: {
  201. on: {
  202. POINTED_CANVAS: {
  203. do: "createRay",
  204. to: "ray.editing",
  205. },
  206. },
  207. },
  208. editing: {
  209. on: {
  210. STOPPED_POINTING: { do: "completeSession", to: "selecting" },
  211. CANCELLED: {
  212. do: ["cancelSession", "deleteSelectedIds"],
  213. to: "selecting",
  214. },
  215. },
  216. initial: "inactive",
  217. states: {
  218. inactive: {
  219. on: {
  220. MOVED_POINTER: {
  221. if: "distanceImpliesDrag",
  222. to: "ray.editing.active",
  223. },
  224. },
  225. },
  226. active: {
  227. onEnter: "startDirectionSession",
  228. on: {
  229. MOVED_POINTER: "updateDirectionSession",
  230. PANNED_CAMERA: "updateDirectionSession",
  231. },
  232. },
  233. },
  234. },
  235. },
  236. },
  237. line: {
  238. initial: "creating",
  239. states: {
  240. creating: {
  241. on: {
  242. POINTED_CANVAS: {
  243. do: "createLine",
  244. to: "line.editing",
  245. },
  246. },
  247. },
  248. editing: {
  249. on: {
  250. STOPPED_POINTING: { do: "completeSession", to: "selecting" },
  251. CANCELLED: {
  252. do: ["cancelSession", "deleteSelectedIds"],
  253. to: "selecting",
  254. },
  255. },
  256. initial: "inactive",
  257. states: {
  258. inactive: {
  259. on: {
  260. MOVED_POINTER: {
  261. if: "distanceImpliesDrag",
  262. to: "line.editing.active",
  263. },
  264. },
  265. },
  266. active: {
  267. onEnter: "startDirectionSession",
  268. on: {
  269. MOVED_POINTER: "updateDirectionSession",
  270. PANNED_CAMERA: "updateDirectionSession",
  271. },
  272. },
  273. },
  274. },
  275. },
  276. },
  277. polyline: {},
  278. rectangle: {},
  279. },
  280. conditions: {
  281. isPointingBounds(data, payload: PointerInfo) {
  282. return payload.target === "bounds"
  283. },
  284. isReadOnly(data) {
  285. return data.isReadOnly
  286. },
  287. distanceImpliesDrag(data, payload: PointerInfo) {
  288. return vec.dist2(payload.origin, payload.point) > 16
  289. },
  290. isPointedShapeSelected(data) {
  291. return data.selectedIds.has(data.pointedId)
  292. },
  293. isPressingShiftKey(data, payload: { shiftKey: boolean }) {
  294. return payload.shiftKey
  295. },
  296. shapeIsHovered(data, payload: { target: string }) {
  297. return data.hoveredId === payload.target
  298. },
  299. pointHitsShape(data, payload: { target: string; point: number[] }) {
  300. const shape =
  301. data.document.pages[data.currentPageId].shapes[payload.target]
  302. return getShapeUtils(shape).hitTest(
  303. shape,
  304. screenToWorld(payload.point, data)
  305. )
  306. },
  307. },
  308. actions: {
  309. /* --------------------- Shapes --------------------- */
  310. // Dot
  311. createDot(data, payload: PointerInfo) {
  312. const shape = shapeUtilityMap[ShapeType.Dot].create({
  313. point: screenToWorld(payload.point, data),
  314. })
  315. commands.createShape(data, shape)
  316. data.selectedIds.add(shape.id)
  317. },
  318. // Ray
  319. createRay(data, payload: PointerInfo) {
  320. const shape = shapeUtilityMap[ShapeType.Ray].create({
  321. point: screenToWorld(payload.point, data),
  322. })
  323. commands.createShape(data, shape)
  324. data.selectedIds.add(shape.id)
  325. },
  326. // Line
  327. createLine(data, payload: PointerInfo) {
  328. const shape = shapeUtilityMap[ShapeType.Line].create({
  329. point: screenToWorld(payload.point, data),
  330. direction: [0, 1],
  331. })
  332. commands.createShape(data, shape)
  333. data.selectedIds.add(shape.id)
  334. },
  335. /* -------------------- Sessions -------------------- */
  336. // Shared
  337. cancelSession(data) {
  338. session?.cancel(data)
  339. session = undefined
  340. },
  341. completeSession(data) {
  342. session?.complete(data)
  343. session = undefined
  344. },
  345. // Brushing
  346. startBrushSession(data, payload: PointerInfo) {
  347. session = new Sessions.BrushSession(
  348. data,
  349. screenToWorld(payload.point, data)
  350. )
  351. },
  352. updateBrushSession(data, payload: PointerInfo) {
  353. session.update(data, screenToWorld(payload.point, data))
  354. },
  355. // Dragging / Translating
  356. startTranslateSession(data, payload: PointerInfo) {
  357. session = new Sessions.TranslateSession(
  358. data,
  359. screenToWorld(payload.point, data)
  360. )
  361. },
  362. updateTranslateSession(data, payload: PointerInfo) {
  363. session.update(data, screenToWorld(payload.point, data))
  364. },
  365. // Dragging / Translating
  366. startTransformSession(
  367. data,
  368. payload: PointerInfo & { target: TransformCorner | TransformEdge }
  369. ) {
  370. session = new Sessions.TransformSession(
  371. data,
  372. payload.target,
  373. screenToWorld(payload.point, data)
  374. )
  375. },
  376. updateTransformSession(data, payload: PointerInfo) {
  377. session.update(data, screenToWorld(payload.point, data))
  378. },
  379. // Direction
  380. startDirectionSession(data, payload: PointerInfo) {
  381. session = new Sessions.DirectionSession(
  382. data,
  383. screenToWorld(payload.point, data)
  384. )
  385. },
  386. updateDirectionSession(data, payload: PointerInfo) {
  387. session.update(data, screenToWorld(payload.point, data))
  388. },
  389. /* -------------------- Selection ------------------- */
  390. setHoveredId(data, payload: PointerInfo) {
  391. data.hoveredId = payload.target
  392. },
  393. clearHoveredId(data) {
  394. data.hoveredId = undefined
  395. },
  396. setPointedId(data, payload: PointerInfo) {
  397. data.pointedId = payload.target
  398. },
  399. clearPointedId(data) {
  400. data.pointedId = undefined
  401. },
  402. clearSelectedIds(data) {
  403. data.selectedIds.clear()
  404. },
  405. pullPointedIdFromSelectedIds(data) {
  406. const { selectedIds, pointedId } = data
  407. selectedIds.delete(pointedId)
  408. },
  409. pushPointedIdToSelectedIds(data) {
  410. data.selectedIds.add(data.pointedId)
  411. },
  412. // Camera
  413. zoomCamera(data, payload: { delta: number; point: number[] }) {
  414. const { camera } = data
  415. const p0 = screenToWorld(payload.point, data)
  416. camera.zoom = clamp(
  417. camera.zoom - (payload.delta / 100) * camera.zoom,
  418. 0.5,
  419. 3
  420. )
  421. const p1 = screenToWorld(payload.point, data)
  422. camera.point = vec.add(camera.point, vec.sub(p1, p0))
  423. document.documentElement.style.setProperty(
  424. "--camera-zoom",
  425. camera.zoom.toString()
  426. )
  427. },
  428. panCamera(data, payload: { delta: number[]; point: number[] }) {
  429. const { camera } = data
  430. data.camera.point = vec.sub(
  431. camera.point,
  432. vec.div(payload.delta, camera.zoom)
  433. )
  434. },
  435. deleteSelectedIds(data) {
  436. const { document, currentPageId } = data
  437. const { shapes } = document.pages[currentPageId]
  438. data.hoveredId = undefined
  439. data.pointedId = undefined
  440. data.selectedIds.forEach((id) => {
  441. delete shapes[id]
  442. // TODO: recursively delete children
  443. })
  444. data.document.pages[currentPageId].shapes = shapes
  445. data.selectedIds.clear()
  446. },
  447. /* ---------------------- Misc ---------------------- */
  448. // History
  449. enableHistory() {
  450. history.enable()
  451. },
  452. disableHistory() {
  453. history.disable()
  454. },
  455. undo(data) {
  456. history.undo(data)
  457. },
  458. redo(data) {
  459. history.redo(data)
  460. },
  461. // Code
  462. setGeneratedShapes(data, payload: { shapes: Shape[] }) {
  463. commands.generateShapes(data, data.currentPageId, payload.shapes)
  464. },
  465. increaseCodeFontSize(data) {
  466. data.settings.fontSize++
  467. },
  468. decreaseCodeFontSize(data) {
  469. data.settings.fontSize--
  470. },
  471. },
  472. values: {
  473. selectedIds(data) {
  474. return new Set(data.selectedIds)
  475. },
  476. selectedBounds(data) {
  477. const {
  478. selectedIds,
  479. currentPageId,
  480. document: { pages },
  481. } = data
  482. const shapes = Array.from(selectedIds.values()).map(
  483. (id) => pages[currentPageId].shapes[id]
  484. )
  485. if (selectedIds.size === 0) return null
  486. if (selectedIds.size === 1 && !getShapeUtils(shapes[0]).canTransform) {
  487. return null
  488. }
  489. return getCommonBounds(
  490. ...shapes.map((shape) => getShapeUtils(shape).getBounds(shape))
  491. )
  492. },
  493. },
  494. })
  495. let session: Sessions.BaseSession
  496. export default state
  497. export const useSelector = createSelectorHook(state)