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 15KB

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