Bladeren bron

adds commands, brush, history

main
Steve Ruiz 4 jaren geleden
bovenliggende
commit
a5659380c4

+ 1
- 0
components/canvas/bounds-bg.tsx Bestand weergeven

@@ -0,0 +1 @@
1
+export default function BoundsBg() {}

+ 1
- 0
components/canvas/bounds.tsx Bestand weergeven

@@ -0,0 +1 @@
1
+export default function Bounds() {}

+ 23
- 0
components/canvas/brush.tsx Bestand weergeven

@@ -0,0 +1,23 @@
1
+import { useSelector } from "state"
2
+import styled from "styles"
3
+
4
+export default function Brush() {
5
+  const brush = useSelector(({ data }) => data.brush)
6
+
7
+  if (!brush) return null
8
+
9
+  return (
10
+    <BrushRect
11
+      x={brush.minX}
12
+      y={brush.minY}
13
+      width={brush.width}
14
+      height={brush.height}
15
+      className="brush"
16
+    />
17
+  )
18
+}
19
+
20
+const BrushRect = styled("rect", {
21
+  fill: "$brushFill",
22
+  stroke: "$brushStroke",
23
+})

+ 26
- 2
components/canvas/canvas.tsx Bestand weergeven

@@ -1,8 +1,11 @@
1 1
 import styled from "styles"
2
-import { useRef } from "react"
2
+import { getPointerEventInfo } from "utils/utils"
3
+import React, { useCallback, useRef } from "react"
3 4
 import useZoomEvents from "hooks/useZoomEvents"
4 5
 import useCamera from "hooks/useCamera"
5 6
 import Page from "./page"
7
+import Brush from "./brush"
8
+import state from "state"
6 9
 
7 10
 export default function Canvas() {
8 11
   const rCanvas = useRef<SVGSVGElement>(null)
@@ -11,10 +14,31 @@ export default function Canvas() {
11 14
 
12 15
   useCamera(rGroup)
13 16
 
17
+  const handlePointerDown = useCallback((e: React.PointerEvent) => {
18
+    rCanvas.current.setPointerCapture(e.pointerId)
19
+    state.send("POINTED_CANVAS", getPointerEventInfo(e))
20
+  }, [])
21
+
22
+  const handlePointerMove = useCallback((e: React.PointerEvent) => {
23
+    state.send("MOVED_POINTER", getPointerEventInfo(e))
24
+  }, [])
25
+
26
+  const handlePointerUp = useCallback((e: React.PointerEvent) => {
27
+    rCanvas.current.releasePointerCapture(e.pointerId)
28
+    state.send("STOPPED_POINTING", getPointerEventInfo(e))
29
+  }, [])
30
+
14 31
   return (
15
-    <MainSVG ref={rCanvas} {...events}>
32
+    <MainSVG
33
+      ref={rCanvas}
34
+      {...events}
35
+      onPointerDown={handlePointerDown}
36
+      onPointerMove={handlePointerMove}
37
+      onPointerUp={handlePointerUp}
38
+    >
16 39
       <MainGroup ref={rGroup}>
17 40
         <Page />
41
+        <Brush />
18 42
       </MainGroup>
19 43
     </MainSVG>
20 44
   )

+ 94
- 0
state/commands/command.ts Bestand weergeven

@@ -0,0 +1,94 @@
1
+import { Data } from "types"
2
+
3
+/* ------------------ Command Class ----------------- */
4
+
5
+export type CommandFn<T> = (data: T, initial?: boolean) => void
6
+
7
+export enum CommandType {
8
+  ChangeBounds,
9
+  CreateGlob,
10
+  CreateNode,
11
+  Delete,
12
+  Split,
13
+  Move,
14
+  MoveAnchor,
15
+  ReorderGlobs,
16
+  ReorderNodes,
17
+  Paste,
18
+  ToggleCap,
19
+  ToggleLocked,
20
+  SetProperty,
21
+  SetItems,
22
+  Transform,
23
+}
24
+
25
+/**
26
+ * A command makes changes to some applicate state. Every command has an "undo"
27
+ * method to reverse its changes. The apps history is a series of commands.
28
+ */
29
+export class BaseCommand<T extends any> {
30
+  timestamp = Date.now()
31
+  private undoFn: CommandFn<T>
32
+  private doFn: CommandFn<T>
33
+  protected restoreBeforeSelectionState: (data: T) => void
34
+  protected restoreAfterSelectionState: (data: T) => void
35
+  protected saveSelectionState: (data: T) => (data: T) => void
36
+  protected manualSelection: boolean
37
+
38
+  constructor(options: {
39
+    type: CommandType
40
+    do: CommandFn<T>
41
+    undo: CommandFn<T>
42
+    manualSelection?: boolean
43
+  }) {
44
+    this.doFn = options.do
45
+    this.undoFn = options.undo
46
+    this.manualSelection = options.manualSelection || false
47
+    this.restoreBeforeSelectionState = () => () => {
48
+      null
49
+    }
50
+    this.restoreAfterSelectionState = () => () => {
51
+      null
52
+    }
53
+  }
54
+
55
+  undo = (data: T) => {
56
+    if (this.manualSelection) {
57
+      this.undoFn(data)
58
+      return
59
+    }
60
+
61
+    // We need to set the selection state to what it was before we after we did the command
62
+    this.restoreAfterSelectionState(data)
63
+    this.undoFn(data)
64
+    this.restoreBeforeSelectionState(data)
65
+  }
66
+
67
+  redo = (data: T, initial = false) => {
68
+    if (initial) {
69
+      this.restoreBeforeSelectionState = this.saveSelectionState(data)
70
+    } else {
71
+      this.restoreBeforeSelectionState(data)
72
+    }
73
+
74
+    // We need to set the selection state to what it was before we did the command
75
+    this.doFn(data, initial)
76
+
77
+    if (initial) {
78
+      this.restoreAfterSelectionState = this.saveSelectionState(data)
79
+    }
80
+  }
81
+}
82
+
83
+/* ---------------- Project Specific ---------------- */
84
+
85
+/**
86
+ * A subclass of BaseCommand that sends events to our state. In our case, we want our actions
87
+ * to mutate the state's data. Actions do not effect the "active states" in
88
+ * the app.
89
+ */
90
+export class Command extends BaseCommand<Data> {
91
+  saveSelectionState = (data: Data) => {
92
+    return (data: Data) => {}
93
+  }
94
+}

+ 63
- 0
state/commands/history.ts Bestand weergeven

@@ -0,0 +1,63 @@
1
+import { Data } from "types"
2
+import { BaseCommand } from "./command"
3
+
4
+class BaseHistory<T> {
5
+  private stack: BaseCommand<T>[] = []
6
+  private pointer = -1
7
+  private maxLength = 100
8
+  private _enabled = true
9
+
10
+  execute = (data: T, command: BaseCommand<T>) => {
11
+    if (this.disabled) return
12
+    this.stack = this.stack.slice(0, this.pointer + 1)
13
+    this.stack.push(command)
14
+    command.redo(data, true)
15
+    this.pointer++
16
+
17
+    if (this.stack.length > this.maxLength) {
18
+      this.stack = this.stack.slice(this.stack.length - this.maxLength)
19
+      this.pointer = this.maxLength - 1
20
+    }
21
+
22
+    this.save(data)
23
+  }
24
+
25
+  undo = (data: T) => {
26
+    if (this.disabled) return
27
+    if (this.pointer === -1) return
28
+    const command = this.stack[this.pointer]
29
+    command.undo(data)
30
+    this.pointer--
31
+    this.save(data)
32
+  }
33
+
34
+  redo = (data: T) => {
35
+    if (this.disabled) return
36
+    if (this.pointer === this.stack.length - 1) return
37
+    const command = this.stack[this.pointer + 1]
38
+    command.redo(data, false)
39
+    this.pointer++
40
+    this.save(data)
41
+  }
42
+
43
+  save = (data: T) => {
44
+    if (typeof window === "undefined") return
45
+    if (typeof localStorage === "undefined") return
46
+
47
+    localStorage.setItem("glob_aldata_v6", JSON.stringify(data))
48
+  }
49
+
50
+  disable = () => {
51
+    this._enabled = false
52
+  }
53
+
54
+  enable = () => {
55
+    this._enabled = true
56
+  }
57
+
58
+  get disabled() {
59
+    return !this._enabled
60
+  }
61
+}
62
+
63
+export default new BaseHistory<Data>()

+ 44
- 0
state/data.ts Bestand weergeven

@@ -0,0 +1,44 @@
1
+import { Data, ShapeType } from "types"
2
+
3
+export const defaultDocument: Data["document"] = {
4
+  pages: {
5
+    page0: {
6
+      id: "page0",
7
+      type: "page",
8
+      name: "Page 0",
9
+      childIndex: 0,
10
+      shapes: {
11
+        shape0: {
12
+          id: "shape0",
13
+          type: ShapeType.Circle,
14
+          name: "Shape 0",
15
+          parentId: "page0",
16
+          childIndex: 1,
17
+          point: [100, 100],
18
+          radius: 50,
19
+          rotation: 0,
20
+        },
21
+        shape1: {
22
+          id: "shape1",
23
+          type: ShapeType.Rectangle,
24
+          name: "Shape 1",
25
+          parentId: "page0",
26
+          childIndex: 1,
27
+          point: [300, 300],
28
+          size: [200, 200],
29
+          rotation: 0,
30
+        },
31
+        shape2: {
32
+          id: "shape2",
33
+          type: ShapeType.Circle,
34
+          name: "Shape 2",
35
+          parentId: "page0",
36
+          childIndex: 2,
37
+          point: [200, 800],
38
+          radius: 25,
39
+          rotation: 0,
40
+        },
41
+      },
42
+    },
43
+  },
44
+}

+ 17
- 0
state/sessions/base-session.ts Bestand weergeven

@@ -0,0 +1,17 @@
1
+import { Data } from "types"
2
+
3
+export default class BaseSession {
4
+  constructor(data: Data) {}
5
+
6
+  update = (data: Data, ...args: unknown[]) => {
7
+    // Update the state
8
+  }
9
+
10
+  complete = (data: Data, ...args: unknown[]) => {
11
+    // Create a command
12
+  }
13
+
14
+  cancel = (data: Data) => {
15
+    // Clean up the change
16
+  }
17
+}

+ 64
- 0
state/sessions/brush-session.ts Bestand weergeven

@@ -0,0 +1,64 @@
1
+import { current } from "immer"
2
+import { Bounds, Data, Shape } from "types"
3
+import BaseSession from "./base-session"
4
+import { screenToWorld, getBoundsFromPoints } from "utils/utils"
5
+import * as vec from "utils/vec"
6
+
7
+interface BrushSnapshot {
8
+  selectedIds: string[]
9
+  shapes: Shape[]
10
+}
11
+
12
+export default class BrushSession extends BaseSession {
13
+  origin: number[]
14
+  snapshot: BrushSnapshot
15
+
16
+  constructor(data: Data, point: number[]) {
17
+    super(data)
18
+
19
+    this.origin = vec.round(point)
20
+
21
+    this.snapshot = BrushSession.getSnapshot(data)
22
+  }
23
+
24
+  update = (data: Data, point: number[]) => {
25
+    const { origin, snapshot } = this
26
+
27
+    const bounds = getBoundsFromPoints(origin, point)
28
+
29
+    data.brush = bounds
30
+
31
+    const { minX: x, minY: y, width: w, height: h } = bounds
32
+
33
+    data.selectedIds = [
34
+      ...snapshot.selectedIds,
35
+      ...snapshot.shapes.map((shape) => {
36
+        return shape.id
37
+      }),
38
+    ]
39
+
40
+    // Narrow the  the items on the screen
41
+    data.brush = bounds
42
+  }
43
+
44
+  cancel = (data: Data) => {
45
+    data.brush = undefined
46
+    data.selectedIds = this.snapshot.selectedIds
47
+  }
48
+
49
+  complete = (data: Data) => {
50
+    data.brush = undefined
51
+  }
52
+
53
+  static getSnapshot(data: Data) {
54
+    const {
55
+      document: { pages },
56
+      currentPageId,
57
+    } = current(data)
58
+
59
+    return {
60
+      selectedIds: [...data.selectedIds],
61
+      shapes: Object.values(pages[currentPageId].shapes),
62
+    }
63
+  }
64
+}

+ 4
- 0
state/sessions/index.ts Bestand weergeven

@@ -0,0 +1,4 @@
1
+import BaseSession from "./brush-session"
2
+import BrushSession from "./brush-session"
3
+
4
+export { BrushSession, BaseSession }

+ 39
- 43
state/state.ts Bestand weergeven

@@ -1,56 +1,20 @@
1 1
 import { createSelectorHook, createState } from "@state-designer/react"
2 2
 import { clamp, screenToWorld } from "utils/utils"
3 3
 import * as vec from "utils/vec"
4
-import { Data, ShapeType } from "types"
4
+import { Data } from "types"
5
+import { defaultDocument } from "./data"
6
+import * as Sessions from "./sessions"
5 7
 
6 8
 const initialData: Data = {
7 9
   camera: {
8 10
     point: [0, 0],
9 11
     zoom: 1,
10 12
   },
13
+  brush: undefined,
14
+  pointedId: null,
15
+  selectedIds: [],
11 16
   currentPageId: "page0",
12
-  document: {
13
-    pages: {
14
-      page0: {
15
-        id: "page0",
16
-        type: "page",
17
-        name: "Page 0",
18
-        childIndex: 0,
19
-        shapes: {
20
-          shape0: {
21
-            id: "shape0",
22
-            type: ShapeType.Circle,
23
-            name: "Shape 0",
24
-            parentId: "page0",
25
-            childIndex: 1,
26
-            point: [100, 100],
27
-            radius: 50,
28
-            rotation: 0,
29
-          },
30
-          shape1: {
31
-            id: "shape1",
32
-            type: ShapeType.Rectangle,
33
-            name: "Shape 1",
34
-            parentId: "page0",
35
-            childIndex: 1,
36
-            point: [300, 300],
37
-            size: [200, 200],
38
-            rotation: 0,
39
-          },
40
-          shape2: {
41
-            id: "shape2",
42
-            type: ShapeType.Circle,
43
-            name: "Shape 2",
44
-            parentId: "page0",
45
-            childIndex: 2,
46
-            point: [200, 800],
47
-            radius: 25,
48
-            rotation: 0,
49
-          },
50
-        },
51
-      },
52
-    },
53
-  },
17
+  document: defaultDocument,
54 18
 }
55 19
 
56 20
 const state = createState({
@@ -63,7 +27,37 @@ const state = createState({
63 27
       do: "panCamera",
64 28
     },
65 29
   },
30
+  initial: "selecting",
31
+  states: {
32
+    selecting: {
33
+      on: {
34
+        POINTED_CANVAS: { to: "brushSelecting" },
35
+      },
36
+    },
37
+    brushSelecting: {
38
+      onEnter: "startBrushSession",
39
+      on: {
40
+        MOVED_POINTER: "updateBrushSession",
41
+        STOPPED_POINTING: { do: "completeSession", to: "selecting" },
42
+        CANCELLED: { do: "cancelSession", to: "selecting" },
43
+      },
44
+    },
45
+  },
66 46
   actions: {
47
+    cancelSession(data) {
48
+      session.cancel(data)
49
+      session = undefined
50
+    },
51
+    completeSession(data) {
52
+      session.complete(data)
53
+      session = undefined
54
+    },
55
+    startBrushSession(data, { point }) {
56
+      session = new Sessions.BrushSession(data, point)
57
+    },
58
+    updateBrushSession(data, { point }) {
59
+      session.update(data, point)
60
+    },
67 61
     zoomCamera(data, payload: { delta: number; point: number[] }) {
68 62
       const { camera } = data
69 63
       const p0 = screenToWorld(payload.point, data)
@@ -85,6 +79,8 @@ const state = createState({
85 79
   },
86 80
 })
87 81
 
82
+let session: Sessions.BaseSession
83
+
88 84
 export default state
89 85
 
90 86
 export const useSelector = createSelectorHook(state)

+ 4
- 1
styles/stitches.config.ts Bestand weergeven

@@ -5,7 +5,10 @@ const { styled, global, css, theme, getCssString } = createCss({
5 5
     ...defaultThemeMap,
6 6
   },
7 7
   theme: {
8
-    colors: {},
8
+    colors: {
9
+      brushFill: "rgba(0,0,0,.1)",
10
+      brushStroke: "rgba(0,0,0,.5)",
11
+    },
9 12
     space: {},
10 13
     fontSizes: {
11 14
       0: "10px",

+ 11
- 1
types.ts Bestand weergeven

@@ -3,9 +3,10 @@ export interface Data {
3 3
     point: number[]
4 4
     zoom: number
5 5
   }
6
+  brush?: Bounds
6 7
   currentPageId: string
7 8
   selectedIds: string[]
8
-  pointedId: string
9
+  pointedId?: string
9 10
   document: {
10 11
     pages: Record<string, Page>
11 12
   }
@@ -93,3 +94,12 @@ export type Shape =
93 94
   | RayShape
94 95
   | LineSegmentShape
95 96
   | RectangleShape
97
+
98
+export interface Bounds {
99
+  minX: number
100
+  minY: number
101
+  maxX: number
102
+  maxY: number
103
+  width: number
104
+  height: number
105
+}

+ 21
- 0
utils/utils.ts Bestand weergeven

@@ -6,6 +6,22 @@ export function screenToWorld(point: number[], data: Data) {
6 6
   return vec.add(vec.div(point, data.camera.zoom), data.camera.point)
7 7
 }
8 8
 
9
+export function getBoundsFromPoints(a: number[], b: number[]) {
10
+  const minX = Math.min(a[0], b[0])
11
+  const maxX = Math.max(a[0], b[0])
12
+  const minY = Math.min(a[1], b[1])
13
+  const maxY = Math.max(a[1], b[1])
14
+
15
+  return {
16
+    minX,
17
+    maxX,
18
+    minY,
19
+    maxY,
20
+    width: maxX - minX,
21
+    height: maxY - minY,
22
+  }
23
+}
24
+
9 25
 // A helper for getting tangents.
10 26
 export function getCircleTangentToPoint(
11 27
   A: number[],
@@ -827,3 +843,8 @@ export async function postJsonToEndpoint(
827 843
 
828 844
   return await d.json()
829 845
 }
846
+
847
+export function getPointerEventInfo(e: React.PointerEvent) {
848
+  const { shiftKey, ctrlKey, metaKey, altKey } = e
849
+  return { point: [e.clientX, e.clientY], shiftKey, ctrlKey, metaKey, altKey }
850
+}

Laden…
Annuleren
Opslaan