Bladeren bron

Adds tests, fixes bug on pre-complete sessions

main
Steve Ruiz 4 jaren geleden
bovenliggende
commit
a24b7f7931

+ 13343
- 0
__tests__/__mocks__/data.json
Diff onderdrukt omdat het te groot bestand
Bestand weergeven


+ 19858
- 0
__tests__/__mocks__/document.json
Diff onderdrukt omdat het te groot bestand
Bestand weergeven


__tests__/__snapshots__/dashes.ts.snap → __tests__/__snapshots__/dashes.test.ts.snap Bestand weergeven


__tests__/dashes.ts → __tests__/dashes.test.ts Bestand weergeven


+ 56
- 0
__tests__/project.test.ts Bestand weergeven

@@ -0,0 +1,56 @@
1
+import * as json from './__mocks__/document.json'
2
+import state from 'state'
3
+import { point } from './test-utils'
4
+import inputs from 'state/inputs'
5
+import { getSelectedIds, setToArray } from 'utils/utils'
6
+
7
+const rectangleId = '1f6c251c-e12e-40b4-8dd2-c1847d80b72f'
8
+const arrowId = '5ca167d7-54de-47c9-aa8f-86affa25e44d'
9
+
10
+describe('project', () => {
11
+  it('mounts the state', () => {
12
+    state.enableLog(true)
13
+
14
+    state
15
+      .send('MOUNTED')
16
+      .send('LOADED_FROM_FILE', { json: JSON.stringify(json) })
17
+  })
18
+
19
+  it('selects and deselects a shape', () => {
20
+    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([])
21
+
22
+    state
23
+      .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
24
+      .send('STOPPED_POINTING', inputs.pointerUp(point()))
25
+
26
+    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([rectangleId])
27
+
28
+    state
29
+      .send('POINTED_CANVAS', inputs.pointerDown(point(), 'canvas'))
30
+      .send('STOPPED_POINTING', inputs.pointerUp(point()))
31
+
32
+    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([])
33
+  })
34
+
35
+  it('selects multiple shapes', () => {
36
+    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([])
37
+
38
+    state
39
+      .send('POINTED_SHAPE', inputs.pointerDown(point(), rectangleId))
40
+      .send('STOPPED_POINTING', inputs.pointerUp(point()))
41
+
42
+    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([rectangleId])
43
+
44
+    state.send(
45
+      'POINTED_SHAPE',
46
+      inputs.pointerDown(point({ shiftKey: true }), arrowId)
47
+    )
48
+
49
+    // state.send('STOPPED_POINTING', inputs.pointerUp(point()))
50
+
51
+    expect(setToArray(getSelectedIds(state.data))).toStrictEqual([
52
+      rectangleId,
53
+      arrowId,
54
+    ])
55
+  })
56
+})

+ 30
- 0
__tests__/test-utils.ts Bestand weergeven

@@ -0,0 +1,30 @@
1
+interface PointerOptions {
2
+  id?: string
3
+  x?: number
4
+  y?: number
5
+  shiftKey?: boolean
6
+  altKey?: boolean
7
+  metaKey?: boolean
8
+}
9
+
10
+export function point(
11
+  options: PointerOptions = {} as PointerOptions
12
+): PointerEvent {
13
+  const {
14
+    id = '1',
15
+    x = 0,
16
+    y = 0,
17
+    shiftKey = false,
18
+    altKey = false,
19
+    metaKey = false,
20
+  } = options
21
+
22
+  return {
23
+    shiftKey,
24
+    altKey,
25
+    metaKey,
26
+    pointerId: id,
27
+    clientX: x,
28
+    clientY: y,
29
+  } as any
30
+}

+ 4
- 0
hooks/useKeyboardEvents.ts Bestand weergeven

@@ -246,6 +246,10 @@ export default function useKeyboardEvents() {
246 246
           }
247 247
           break
248 248
         }
249
+        case '|': {
250
+          state.send('COPIED_STATE_TO_CLIPBOARD')
251
+          break
252
+        }
249 253
         default: {
250 254
           state.send('PRESSED_KEY', getKeyboardEventInfo(e))
251 255
         }

+ 1
- 0
jest.config.js Bestand weergeven

@@ -8,6 +8,7 @@ module.exports = {
8 8
     '^.+\\.(ts|tsx|mjs)$': 'babel-jest',
9 9
   },
10 10
   modulePaths: ['<rootDir>', 'node_modules'],
11
+  testMatch: ["**/__tests__/**/*test.[t]s?(x)"],
11 12
   watchPlugins: [
12 13
     'jest-watch-typeahead/filename',
13 14
     'jest-watch-typeahead/testname',

+ 3
- 2
package.json Bestand weergeven

@@ -64,12 +64,13 @@
64 64
   },
65 65
   "devDependencies": {
66 66
     "@testing-library/react": "^11.2.5",
67
+    "@testing-library/user-event": "^13.1.9",
67 68
     "@types/jest": "^26.0.23",
68 69
     "@types/node": "^14.14.25",
69 70
     "@types/react": "^17.0.1",
70 71
     "@typescript-eslint/eslint-plugin": "^4.14.2",
71 72
     "@typescript-eslint/parser": "^4.14.2",
72
-    "babel-jest": "^27.0.2",
73
+    "babel-jest": "^27.0.5",
73 74
     "eslint": "^7.19.0",
74 75
     "eslint-config-next": "^11.0.0",
75 76
     "eslint-config-prettier": "^7.2.0",
@@ -88,4 +89,4 @@
88 89
     "tabWidth": 2,
89 90
     "useTabs": false
90 91
   }
91
-}
92
+}

+ 6
- 6
state/hacks.ts Bestand weergeven

@@ -10,6 +10,7 @@ import { freeze } from 'immer'
10 10
 import session from './session'
11 11
 import state from './state'
12 12
 import vec from 'utils/vec'
13
+import * as Session from './sessions'
13 14
 
14 15
 /**
15 16
  * While a user is drawing with the draw tool, we want to update the shape without
@@ -21,7 +22,7 @@ import vec from 'utils/vec'
21 22
 export function fastDrawUpdate(info: PointerInfo): void {
22 23
   const data = { ...state.data }
23 24
 
24
-  session.current.update(
25
+  session.update<Session.DrawSession>(
25 26
     data,
26 27
     screenToWorld(info.point, data),
27 28
     info.pressure,
@@ -91,7 +92,7 @@ export function fastPinchCamera(
91 92
 export function fastBrushSelect(point: number[]): void {
92 93
   const data = { ...state.data }
93 94
 
94
-  session.current.update(data, screenToWorld(point, data))
95
+  session.update<Session.BrushSession>(data, screenToWorld(point, data))
95 96
 
96 97
   state.forceData(freeze(data))
97 98
 }
@@ -99,7 +100,7 @@ export function fastBrushSelect(point: number[]): void {
99 100
 export function fastTranslate(info: PointerInfo): void {
100 101
   const data = { ...state.data }
101 102
 
102
-  session.current.update(
103
+  session.update<Session.TranslateSession>(
103 104
     data,
104 105
     screenToWorld(info.point, data),
105 106
     info.shiftKey,
@@ -112,11 +113,10 @@ export function fastTranslate(info: PointerInfo): void {
112 113
 export function fastTransform(info: PointerInfo): void {
113 114
   const data = { ...state.data }
114 115
 
115
-  session.current.update(
116
+  session.update<Session.TransformSession | Session.TransformSingleSession>(
116 117
     data,
117 118
     screenToWorld(info.point, data),
118
-    info.shiftKey,
119
-    info.altKey
119
+    info.shiftKey
120 120
   )
121 121
 
122 122
   state.forceData(freeze(data))

+ 4
- 1
state/inputs.tsx Bestand weergeven

@@ -165,7 +165,6 @@ class Inputs {
165 165
   }
166 166
 
167 167
   canAccept = (pointerId: PointerEvent['pointerId']) => {
168
-    // return true
169 168
     return (
170 169
       this.activePointerId === undefined || this.activePointerId === pointerId
171 170
     )
@@ -187,6 +186,10 @@ class Inputs {
187 186
     this.pointer = undefined
188 187
     this.points = {}
189 188
   }
189
+
190
+  resetDoubleClick() {
191
+    this.pointerUpTime = 0
192
+  }
190 193
 }
191 194
 
192 195
 export default new Inputs()

+ 75
- 14
state/session.ts Bestand weergeven

@@ -1,35 +1,96 @@
1 1
 import { BaseSession } from './sessions'
2 2
 
3
+/**
4
+ * # Session Manager
5
+ *
6
+ * Sessions are instances that manage data mutations for activities such as
7
+ * dragging, transforming, drawing, etc. The session manager singleton provides
8
+ * an API with which the state's actions can begin a session and interact it.
9
+ *
10
+ * A session has a lifecycle:
11
+ *
12
+ * - it `begin`s when the session instance is created
13
+ * - it receives `update`s during the session
14
+ * - it ends either when it is `complete`d or `cancel`led
15
+ *
16
+ * Each session may produce different effects during these life cycles, according
17
+ * to its implementation. Most sessions call a command when completed.
18
+ *
19
+ * It's intended that only a single session occurs at once. This pattern helps
20
+ * ensure that we don't accidentally begin a new session before the current one
21
+ * is cancelled or completes.
22
+ */
23
+
3 24
 class SessionManager {
4
-  private _current?: BaseSession
25
+  #current?: BaseSession
5 26
 
6
-  clear() {
7
-    this._current = undefined
8
-    return this
9
-  }
27
+  /**
28
+   * Begin a new session.
29
+   * @param session A Session instance.
30
+   * @example
31
+   * ```ts
32
+   * session.begin(new Sessions.EditSession(data))
33
+   * ```
34
+   */
35
+  begin(session: BaseSession) {
36
+    if (this.#current) {
37
+      throw Error(
38
+        'Cannot begin a session until the current session is complete. This error indicates a problem with the state chart.'
39
+      )
40
+    }
10 41
 
11
-  setCurrent(session: BaseSession) {
12
-    this._current = session
42
+    this.#current = session
13 43
     return this
14 44
   }
15 45
 
46
+  /**
47
+   * Update the current session. Include the session type as a generic in order to properly type the arguments.
48
+   * @param args The arguments of the current session's `update` method.
49
+   * @example
50
+   * ```ts
51
+   * session.update<Sessions.EditSession>(data, payload.change)
52
+   * ```
53
+   */
16 54
   update<T extends BaseSession>(...args: Parameters<T['update']>) {
17
-    this._current.update.call(null, ...args)
18
-    return this
19
-  }
55
+    const session = this.#current
20 56
 
21
-  begin(session: BaseSession) {
22
-    this._current = session
57
+    if (session === undefined) {
58
+      throw Error('No current session.')
59
+    }
60
+
61
+    session.update.call(this.#current, ...args)
23 62
     return this
24 63
   }
25 64
 
65
+  /**
66
+   * Complete the current session. Include the session type as a generic in order to properly type the arguments.
67
+   * @param args The arguments of the current session's `complete` method.
68
+   * @example
69
+   * ```ts
70
+   * session.update<Sessions.EditSession>(data, payload.change)
71
+   * ```
72
+   */
26 73
   complete<T extends BaseSession>(...args: Parameters<T['complete']>) {
27
-    this._current.complete.call(null, ...args)
74
+    const session = this.#current
75
+
76
+    if (session === undefined) return this
77
+
78
+    session.complete.call(this.#current, ...args)
79
+    this.#current = undefined
28 80
     return this
29 81
   }
30 82
 
83
+  /**
84
+   * Cancel the current session.
85
+   * @param args The arguments of the current session's `cancel` method.
86
+   * @example
87
+   * ```ts
88
+   * session.cancel(data)
89
+   * ```
90
+   */
31 91
   cancel<T extends BaseSession>(...args: Parameters<T['cancel']>) {
32
-    this._current.cancel.call(null, ...args)
92
+    this.#current.cancel.call(this.#current, ...args)
93
+    this.#current = undefined
33 94
     return this
34 95
   }
35 96
 }

+ 4
- 4
state/sessions/base-session.ts Bestand weergeven

@@ -1,20 +1,20 @@
1 1
 /* eslint-disable @typescript-eslint/no-unused-vars */
2 2
 import { Data } from 'types'
3 3
 
4
-export default class BaseSession {
4
+export default abstract class BaseSession {
5 5
   constructor(data: Data) {
6 6
     null
7 7
   }
8 8
 
9 9
   update(data: Data, ...args: unknown[]): void {
10
-    // Update the state
10
+    null
11 11
   }
12 12
 
13 13
   complete(data: Data, ...args: unknown[]): void {
14
-    // Create a command
14
+    null
15 15
   }
16 16
 
17 17
   cancel(data: Data): void {
18
-    // Clean up the change
18
+    null
19 19
   }
20 20
 }

+ 35
- 29
state/state.ts Bestand weergeven

@@ -27,6 +27,7 @@ import {
27 27
   setSelectedIds,
28 28
   getPageState,
29 29
   setToArray,
30
+  copyToClipboard,
30 31
 } from 'utils/utils'
31 32
 import {
32 33
   Data,
@@ -134,6 +135,7 @@ const state = createState({
134 135
         COPIED: { if: 'hasSelection', do: 'copyToClipboard' },
135 136
         PASTED: { do: 'pasteFromClipboard' },
136 137
         PASTED_SHAPES_FROM_CLIPBOARD: 'pasteShapesFromClipboard',
138
+        COPIED_STATE_TO_CLIPBOARD: 'copyStateToClipboard',
137 139
         LOADED_FONTS: 'resetShapes',
138 140
         TOGGLED_SHAPE_LOCK: { if: 'hasSelection', do: 'lockSelection' },
139 141
         TOGGLED_SHAPE_HIDE: { if: 'hasSelection', do: 'hideSelection' },
@@ -266,27 +268,6 @@ const state = createState({
266 268
                   },
267 269
                 },
268 270
                 UNHOVERED_SHAPE: 'clearHoveredId',
269
-                DOUBLE_POINTED_SHAPE: [
270
-                  'setPointedId',
271
-                  {
272
-                    if: 'isPointedShapeSelected',
273
-                    then: {
274
-                      get: 'firstSelectedShape',
275
-                      if: 'canEditSelectedShape',
276
-                      do: 'setEditingId',
277
-                      to: 'editingShape',
278
-                    },
279
-                  },
280
-                  {
281
-                    unless: 'isPressingShiftKey',
282
-                    do: [
283
-                      'setDrilledPointedId',
284
-                      'clearSelectedIds',
285
-                      'pushPointedIdToSelectedIds',
286
-                    ],
287
-                    to: 'pointingBounds',
288
-                  },
289
-                ],
290 271
                 POINTED_SHAPE: [
291 272
                   {
292 273
                     if: 'isPressingMetaKey',
@@ -301,7 +282,7 @@ const state = createState({
301 282
                     unless: 'isPointedShapeSelected',
302 283
                     then: {
303 284
                       if: 'isPressingShiftKey',
304
-                      do: ['pushPointedIdToSelectedIds', 'clearPointedId'],
285
+                      do: 'pushPointedIdToSelectedIds',
305 286
                       else: ['clearSelectedIds', 'pushPointedIdToSelectedIds'],
306 287
                     },
307 288
                   },
@@ -309,6 +290,27 @@ const state = createState({
309 290
                     to: 'pointingBounds',
310 291
                   },
311 292
                 ],
293
+                DOUBLE_POINTED_SHAPE: [
294
+                  'setPointedId',
295
+                  {
296
+                    if: 'isPointedShapeSelected',
297
+                    then: {
298
+                      get: 'firstSelectedShape',
299
+                      if: 'canEditSelectedShape',
300
+                      do: 'setEditingId',
301
+                      to: 'editingShape',
302
+                    },
303
+                  },
304
+                  {
305
+                    unless: 'isPressingShiftKey',
306
+                    do: [
307
+                      'setDrilledPointedId',
308
+                      'clearSelectedIds',
309
+                      'pushPointedIdToSelectedIds',
310
+                    ],
311
+                    to: 'pointingBounds',
312
+                  },
313
+                ],
312 314
                 RIGHT_POINTED: [
313 315
                   {
314 316
                     if: 'isPointingCanvas',
@@ -986,16 +988,16 @@ const state = createState({
986 988
 
987 989
     // Shared
988 990
     breakSession(data) {
989
-      session.cancel(data).clear()
991
+      session.cancel(data)
990 992
       history.disable()
991 993
       commands.deleteSelected(data)
992 994
       history.enable()
993 995
     },
994 996
     cancelSession(data) {
995
-      session.cancel(data).clear()
997
+      session.cancel(data)
996 998
     },
997 999
     completeSession(data) {
998
-      session.complete(data).clear()
1000
+      session.complete(data)
999 1001
     },
1000 1002
 
1001 1003
     // Editing
@@ -1618,7 +1620,7 @@ const state = createState({
1618 1620
       data.settings.isToolLocked = !data.settings.isToolLocked
1619 1621
     },
1620 1622
 
1621
-    /* ---------------------- Data ---------------------- */
1623
+    /* ------------------- Clipboard -------------------- */
1622 1624
 
1623 1625
     copyToSvg(data) {
1624 1626
       clipboard.copySelectionToSvg(data)
@@ -1628,6 +1630,10 @@ const state = createState({
1628 1630
       clipboard.copy(getSelectedShapes(data))
1629 1631
     },
1630 1632
 
1633
+    copyStateToClipboard(data) {
1634
+      copyToClipboard(JSON.stringify(data))
1635
+    },
1636
+
1631 1637
     pasteFromClipboard() {
1632 1638
       clipboard.paste()
1633 1639
     },
@@ -1636,6 +1642,8 @@ const state = createState({
1636 1642
       commands.paste(data, payload.shapes)
1637 1643
     },
1638 1644
 
1645
+    /* ---------------------- Data ---------------------- */
1646
+
1639 1647
     restoreSavedData(data) {
1640 1648
       storage.firstLoad(data)
1641 1649
     },
@@ -1772,9 +1780,7 @@ function getSelectionBounds(data: Data) {
1772 1780
 
1773 1781
   const page = getPage(data)
1774 1782
 
1775
-  const shapes = Array.from(selectedIds.values())
1776
-    .map((id) => page.shapes[id])
1777
-    .filter(Boolean)
1783
+  const shapes = getSelectedShapes(data)
1778 1784
 
1779 1785
   if (selectedIds.size === 0) return null
1780 1786
 

+ 13
- 7
state/storage.ts Bestand weergeven

@@ -1,4 +1,3 @@
1
-import * as fa from 'browser-fs-access'
2 1
 import { Data, PageState, TLDocument } from 'types'
3 2
 import { decompress, compress, setToArray } from 'utils/utils'
4 3
 import state from './state'
@@ -12,7 +11,7 @@ function storageId(fileId: string, label: string, id?: string) {
12 11
 }
13 12
 
14 13
 class Storage {
15
-  previousSaveHandle?: fa.FileSystemHandle
14
+  previousSaveHandle?: any // FileSystemHandle
16 15
 
17 16
   constructor() {
18 17
     // this.loadPreviousHandle() // Still needs debugging
@@ -207,9 +206,8 @@ class Storage {
207 206
   /* ---------------------- Pages --------------------- */
208 207
 
209 208
   async loadPreviousHandle() {
210
-    const handle: fa.FileSystemHandle | undefined = await idb.get(
211
-      'previous_handle'
212
-    )
209
+    const handle = await idb.get('previous_handle')
210
+
213 211
     if (handle !== undefined) {
214 212
       this.previousSaveHandle = handle
215 213
     }
@@ -315,7 +313,11 @@ class Storage {
315 313
     this.saveDataToFileSystem(data, uniqueId(), true)
316 314
   }
317 315
 
318
-  saveDataToFileSystem = (data: Data, fileId: string, saveAs: boolean) => {
316
+  saveDataToFileSystem = async (
317
+    data: Data,
318
+    fileId: string,
319
+    saveAs: boolean
320
+  ) => {
319 321
     const document = this.getCompleteDocument(data)
320 322
 
321 323
     // Then save to file system
@@ -333,6 +335,8 @@ class Storage {
333 335
       }
334 336
     )
335 337
 
338
+    const fa = await import('browser-fs-access')
339
+
336 340
     fa.fileSave(
337 341
       blob,
338 342
       {
@@ -357,7 +361,9 @@ class Storage {
357 361
       })
358 362
   }
359 363
 
360
-  loadDocumentFromFilesystem() {
364
+  async loadDocumentFromFilesystem() {
365
+    const fa = await import('browser-fs-access')
366
+
361 367
     fa.fileOpen({
362 368
       description: 'tldraw files',
363 369
     })

+ 1
- 1
tsconfig.json Bestand weergeven

@@ -1,6 +1,6 @@
1 1
 {
2 2
   "compilerOptions": {
3
-    "target": "es5",
3
+    "target": "es2015",
4 4
     "lib": ["dom", "dom.iterable", "esnext"],
5 5
     "allowJs": true,
6 6
     "skipLibCheck": true,

+ 8
- 1
yarn.lock Bestand weergeven

@@ -1954,6 +1954,13 @@
1954 1954
     "@babel/runtime" "^7.12.5"
1955 1955
     "@testing-library/dom" "^7.28.1"
1956 1956
 
1957
+"@testing-library/user-event@^13.1.9":
1958
+  version "13.1.9"
1959
+  resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-13.1.9.tgz#29e49a42659ac3c1023565ff56819e0153a82e99"
1960
+  integrity sha512-NZr0zL2TMOs2qk+dNlqrAdbaRW5dAmYwd1yuQ4r7HpkVEOj0MWuUjDWwKhcLd/atdBy8ZSMHSKp+kXSQe47ezg==
1961
+  dependencies:
1962
+    "@babel/runtime" "^7.12.5"
1963
+
1957 1964
 "@tootallnate/once@1":
1958 1965
   version "1.1.2"
1959 1966
   resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
@@ -2548,7 +2555,7 @@ axobject-query@^2.2.0:
2548 2555
   resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
2549 2556
   integrity sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==
2550 2557
 
2551
-babel-jest@^27.0.2, babel-jest@^27.0.5:
2558
+babel-jest@^27.0.5:
2552 2559
   version "27.0.5"
2553 2560
   resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-27.0.5.tgz#cd34c033ada05d1362211e5152391fd7a88080c8"
2554 2561
   integrity sha512-bTMAbpCX7ldtfbca2llYLeSFsDM257aspyAOpsdrdSrBqoLkWCy4HPYTXtXWaSLgFPjrJGACL65rzzr4RFGadw==

Laden…
Annuleren
Opslaan