Parcourir la source

Improves outlines

main
Steve Ruiz il y a 4 ans
Parent
révision
addf4185f0

+ 13
- 13
components/canvas/shapes/circle.tsx Voir le fichier

@@ -1,5 +1,5 @@
1
-import { useSelector } from "state"
2 1
 import { CircleShape, ShapeProps } from "types"
2
+import { Indicator, HoverIndicator } from "./indicator"
3 3
 import ShapeGroup from "./shape-g"
4 4
 
5 5
 function BaseCircle({
@@ -9,25 +9,25 @@ function BaseCircle({
9 9
   strokeWidth = 0,
10 10
 }: ShapeProps<CircleShape>) {
11 11
   return (
12
-    <circle
13
-      cx={radius}
14
-      cy={radius}
15
-      r={radius}
16
-      fill={fill}
17
-      stroke={stroke}
18
-      strokeWidth={strokeWidth}
19
-    />
12
+    <>
13
+      <HoverIndicator as="circle" cx={radius} cy={radius} r={radius - 1} />
14
+      <circle
15
+        cx={radius}
16
+        cy={radius}
17
+        r={radius - strokeWidth / 2}
18
+        fill={fill}
19
+        stroke={stroke}
20
+        strokeWidth={strokeWidth}
21
+      />
22
+      <Indicator as="circle" cx={radius} cy={radius} r={radius - 1} />
23
+    </>
20 24
   )
21 25
 }
22 26
 
23 27
 export default function Circle({ id, point, radius }: CircleShape) {
24
-  const isSelected = useSelector((state) => state.values.selectedIds.has(id))
25 28
   return (
26 29
     <ShapeGroup id={id} point={point}>
27 30
       <BaseCircle radius={radius} />
28
-      {isSelected && (
29
-        <BaseCircle radius={radius} fill="none" stroke="blue" strokeWidth={1} />
30
-      )}
31 31
     </ShapeGroup>
32 32
   )
33 33
 }

+ 13
- 14
components/canvas/shapes/dot.tsx Voir le fichier

@@ -1,40 +1,39 @@
1
-import { useSelector } from "state"
1
+import { Indicator, HoverIndicator } from "./indicator"
2 2
 import { DotShape, ShapeProps } from "types"
3 3
 import ShapeGroup from "./shape-g"
4 4
 
5
+const dotRadius = 4
6
+
5 7
 function BaseDot({
6 8
   fill = "#999",
7 9
   stroke = "none",
8
-  strokeWidth = 0,
10
+  strokeWidth = 1,
9 11
 }: ShapeProps<DotShape>) {
10 12
   return (
11 13
     <>
12
-      <circle
13
-        cx={strokeWidth}
14
-        cy={strokeWidth}
15
-        r={8}
16
-        fill="transparent"
17
-        stroke="none"
18
-        strokeWidth="0"
14
+      <HoverIndicator
15
+        as="circle"
16
+        cx={dotRadius}
17
+        cy={dotRadius}
18
+        r={dotRadius - 1}
19 19
       />
20 20
       <circle
21
-        cx={strokeWidth}
22
-        cy={strokeWidth}
23
-        r={Math.max(1, 4 - strokeWidth)}
21
+        cx={dotRadius}
22
+        cy={dotRadius}
23
+        r={dotRadius - strokeWidth / 2}
24 24
         fill={fill}
25 25
         stroke={stroke}
26 26
         strokeWidth={strokeWidth}
27 27
       />
28
+      <Indicator as="circle" cx={dotRadius} cy={dotRadius} r={dotRadius - 1} />
28 29
     </>
29 30
   )
30 31
 }
31 32
 
32 33
 export default function Dot({ id, point }: DotShape) {
33
-  const isSelected = useSelector((state) => state.values.selectedIds.has(id))
34 34
   return (
35 35
     <ShapeGroup id={id} point={point}>
36 36
       <BaseDot />
37
-      {isSelected && <BaseDot fill="none" stroke="blue" strokeWidth={1} />}
38 37
     </ShapeGroup>
39 38
   )
40 39
 }

+ 21
- 0
components/canvas/shapes/indicator.tsx Voir le fichier

@@ -0,0 +1,21 @@
1
+import styled from "styles"
2
+
3
+const Indicator = styled("path", {
4
+  fill: "none",
5
+  stroke: "transparent",
6
+  strokeWidth: "2",
7
+  pointerEvents: "none",
8
+  strokeLineCap: "round",
9
+  strokeLinejoin: "round",
10
+})
11
+
12
+const HoverIndicator = styled("path", {
13
+  fill: "none",
14
+  stroke: "transparent",
15
+  strokeWidth: "8",
16
+  pointerEvents: "all",
17
+  strokeLinecap: "round",
18
+  strokeLinejoin: "round",
19
+})
20
+
21
+export { Indicator, HoverIndicator }

+ 5
- 9
components/canvas/shapes/polyline.tsx Voir le fichier

@@ -1,5 +1,5 @@
1
-import { useSelector } from "state"
2 1
 import { PolylineShape, ShapeProps } from "types"
2
+import { Indicator, HoverIndicator } from "./indicator"
3 3
 import ShapeGroup from "./shape-g"
4 4
 
5 5
 function BasePolyline({
@@ -10,28 +10,24 @@ function BasePolyline({
10 10
 }: ShapeProps<PolylineShape>) {
11 11
   return (
12 12
     <>
13
-      <polyline
14
-        points={points.toString()}
15
-        fill="none"
16
-        stroke="transparent"
17
-        strokeWidth={12}
18
-      />
13
+      <HoverIndicator as="polyline" points={points.toString()} />
19 14
       <polyline
20 15
         points={points.toString()}
21 16
         fill={fill}
22 17
         stroke={stroke}
23 18
         strokeWidth={strokeWidth}
19
+        strokeLinecap="round"
20
+        strokeLinejoin="round"
24 21
       />
22
+      <Indicator as="polyline" points={points.toString()} />
25 23
     </>
26 24
   )
27 25
 }
28 26
 
29 27
 export default function Polyline({ id, point, points }: PolylineShape) {
30
-  const isSelected = useSelector((state) => state.values.selectedIds.has(id))
31 28
   return (
32 29
     <ShapeGroup id={id} point={point}>
33 30
       <BasePolyline points={points} />
34
-      {isSelected && <BasePolyline points={points} fill="none" stroke="blue" />}
35 31
     </ShapeGroup>
36 32
   )
37 33
 }

+ 26
- 14
components/canvas/shapes/rectangle.tsx Voir le fichier

@@ -1,5 +1,5 @@
1
-import { useSelector } from "state"
2 1
 import { RectangleShape, ShapeProps } from "types"
2
+import { HoverIndicator, Indicator } from "./indicator"
3 3
 import ShapeGroup from "./shape-g"
4 4
 
5 5
 function BaseRectangle({
@@ -9,26 +9,38 @@ function BaseRectangle({
9 9
   strokeWidth = 0,
10 10
 }: ShapeProps<RectangleShape>) {
11 11
   return (
12
-    <rect
13
-      x={strokeWidth}
14
-      y={strokeWidth}
15
-      width={size[0] - strokeWidth * 2}
16
-      height={size[1] - strokeWidth * 2}
17
-      fill={fill}
18
-      stroke={stroke}
19
-      strokeWidth={strokeWidth}
20
-    />
12
+    <>
13
+      <HoverIndicator
14
+        as="rect"
15
+        x={1}
16
+        y={1}
17
+        width={size[0] - 2}
18
+        height={size[1] - 2}
19
+      />
20
+      <rect
21
+        x={strokeWidth / 2}
22
+        y={strokeWidth / 2}
23
+        width={size[0] - strokeWidth}
24
+        height={size[1] - strokeWidth}
25
+        fill={fill}
26
+        stroke={stroke}
27
+        strokeWidth={strokeWidth}
28
+      />
29
+      <Indicator
30
+        as="rect"
31
+        x={1}
32
+        y={1}
33
+        width={size[0] - 2}
34
+        height={size[1] - 2}
35
+      />
36
+    </>
21 37
   )
22 38
 }
23 39
 
24 40
 export default function Rectangle({ id, point, size }: RectangleShape) {
25
-  const isSelected = useSelector((state) => state.values.selectedIds.has(id))
26 41
   return (
27 42
     <ShapeGroup id={id} point={point}>
28 43
       <BaseRectangle size={size} />
29
-      {isSelected && (
30
-        <BaseRectangle size={size} fill="none" stroke="blue" strokeWidth={1} />
31
-      )}
32 44
     </ShapeGroup>
33 45
   )
34 46
 }

+ 32
- 3
components/canvas/shapes/shape-g.tsx Voir le fichier

@@ -1,6 +1,8 @@
1
-import state from "state"
1
+import state, { useSelector } from "state"
2 2
 import React, { useCallback, useRef } from "react"
3 3
 import { getPointerEventInfo } from "utils/utils"
4
+import { Indicator, HoverIndicator } from "./indicator"
5
+import styled from "styles"
4 6
 
5 7
 export default function ShapeGroup({
6 8
   id,
@@ -12,6 +14,7 @@ export default function ShapeGroup({
12 14
   point: number[]
13 15
 }) {
14 16
   const rGroup = useRef<SVGGElement>(null)
17
+  const isSelected = useSelector((state) => state.values.selectedIds.has(id))
15 18
 
16 19
   const handlePointerDown = useCallback(
17 20
     (e: React.PointerEvent) => {
@@ -44,8 +47,9 @@ export default function ShapeGroup({
44 47
   )
45 48
 
46 49
   return (
47
-    <g
50
+    <StyledGroup
48 51
       ref={rGroup}
52
+      isSelected={isSelected}
49 53
       transform={`translate(${point})`}
50 54
       onPointerDown={handlePointerDown}
51 55
       onPointerUp={handlePointerUp}
@@ -53,6 +57,31 @@ export default function ShapeGroup({
53 57
       onPointerLeave={handlePointerLeave}
54 58
     >
55 59
       {children}
56
-    </g>
60
+    </StyledGroup>
57 61
   )
58 62
 }
63
+
64
+const StyledGroup = styled("g", {
65
+  [`& ${HoverIndicator}`]: {
66
+    opacity: "0",
67
+  },
68
+  variants: {
69
+    isSelected: {
70
+      true: {
71
+        [`& ${Indicator}`]: {
72
+          stroke: "$selected",
73
+        },
74
+        [`&:hover ${HoverIndicator}`]: {
75
+          opacity: "1",
76
+          stroke: "$hint",
77
+        },
78
+      },
79
+      false: {
80
+        [`&:hover ${HoverIndicator}`]: {
81
+          opacity: "1",
82
+          stroke: "$hint",
83
+        },
84
+      },
85
+    },
86
+  },
87
+})

+ 3
- 0
components/editor.tsx Voir le fichier

@@ -1,7 +1,10 @@
1
+import useKeyboardEvents from "hooks/useKeyboardEvents"
1 2
 import Canvas from "./canvas/canvas"
2 3
 import StatusBar from "./status-bar"
3 4
 
4 5
 export default function Editor() {
6
+  useKeyboardEvents()
7
+
5 8
   return (
6 9
     <>
7 10
       <Canvas />

+ 30
- 0
hooks/useKeyboardEvents.ts Voir le fichier

@@ -0,0 +1,30 @@
1
+import { useEffect } from "react"
2
+import state from "state"
3
+import { getKeyboardEventInfo } from "utils/utils"
4
+
5
+export default function useKeyboardEvents() {
6
+  useEffect(() => {
7
+    function handleKeyDown(e: KeyboardEvent) {
8
+      if (e.key === "Escape") {
9
+        state.send("CANCELLED")
10
+      }
11
+
12
+      state.send("PRESSED_KEY", getKeyboardEventInfo(e))
13
+    }
14
+
15
+    function handleKeyUp(e: KeyboardEvent) {
16
+      if (e.key === "Escape") {
17
+        state.send("CANCELLED")
18
+      }
19
+
20
+      state.send("RELEASED_KEY", getKeyboardEventInfo(e))
21
+    }
22
+
23
+    document.body.addEventListener("keydown", handleKeyDown)
24
+    document.body.addEventListener("keyup", handleKeyUp)
25
+    return () => {
26
+      document.body.removeEventListener("keydown", handleKeyDown)
27
+      document.body.removeEventListener("keyup", handleKeyUp)
28
+    }
29
+  }, [])
30
+}

+ 25
- 18
state/sessions/brush-session.ts Voir le fichier

@@ -10,8 +10,8 @@ import {
10 10
 } from "utils/intersections"
11 11
 
12 12
 interface BrushSnapshot {
13
-  selectedIds: string[]
14
-  shapes: { shape: Shape; test: (bounds: Bounds) => boolean }[]
13
+  selectedIds: Set<string>
14
+  shapes: { id: string; test: (bounds: Bounds) => boolean }[]
15 15
 }
16 16
 
17 17
 export default class BrushSession extends BaseSession {
@@ -31,25 +31,33 @@ export default class BrushSession extends BaseSession {
31 31
 
32 32
     const brushBounds = getBoundsFromPoints(origin, point)
33 33
 
34
-    data.selectedIds = [
35
-      ...snapshot.selectedIds,
36
-      ...snapshot.shapes
37
-        .filter(({ test }) => test(brushBounds))
38
-        .map(({ shape }) => shape.id),
39
-    ]
34
+    for (let { test, id } of snapshot.shapes) {
35
+      if (test(brushBounds)) {
36
+        data.selectedIds.add(id)
37
+      } else if (data.selectedIds.has(id)) {
38
+        data.selectedIds.delete(id)
39
+      }
40
+    }
40 41
 
41 42
     data.brush = brushBounds
42 43
   }
43 44
 
44 45
   cancel = (data: Data) => {
45 46
     data.brush = undefined
46
-    data.selectedIds = this.snapshot.selectedIds
47
+    data.selectedIds = new Set(this.snapshot.selectedIds)
47 48
   }
48 49
 
49 50
   complete = (data: Data) => {
50 51
     data.brush = undefined
51 52
   }
52 53
 
54
+  /**
55
+   * Get a snapshot of the current selected ids, for each shape that is
56
+   * not already selected, the shape's id and a test to see whether the
57
+   * brush will intersect that shape. For tests, start broad -> fine.
58
+   * @param data
59
+   * @returns
60
+   */
53 61
   static getSnapshot(data: Data): BrushSnapshot {
54 62
     const {
55 63
       selectedIds,
@@ -57,19 +65,17 @@ export default class BrushSession extends BaseSession {
57 65
       currentPageId,
58 66
     } = current(data)
59 67
 
60
-    const currentlySelected = new Set(selectedIds)
61
-
62 68
     return {
63
-      selectedIds: [...data.selectedIds],
69
+      selectedIds: new Set(data.selectedIds),
64 70
       shapes: Object.values(pages[currentPageId].shapes)
65
-        .filter((shape) => !currentlySelected.has(shape.id))
71
+        .filter((shape) => !selectedIds.has(shape.id))
66 72
         .map((shape) => {
67 73
           switch (shape.type) {
68 74
             case ShapeType.Dot: {
69 75
               const bounds = shapeUtils[shape.type].getBounds(shape)
70 76
 
71 77
               return {
72
-                shape,
78
+                id: shape.id,
73 79
                 test: (brushBounds: Bounds) =>
74 80
                   boundsContained(bounds, brushBounds) ||
75 81
                   intersectCircleBounds(shape.point, 4, brushBounds).length > 0,
@@ -79,7 +85,7 @@ export default class BrushSession extends BaseSession {
79 85
               const bounds = shapeUtils[shape.type].getBounds(shape)
80 86
 
81 87
               return {
82
-                shape,
88
+                id: shape.id,
83 89
                 test: (brushBounds: Bounds) =>
84 90
                   boundsContained(bounds, brushBounds) ||
85 91
                   intersectCircleBounds(
@@ -93,7 +99,7 @@ export default class BrushSession extends BaseSession {
93 99
               const bounds = shapeUtils[shape.type].getBounds(shape)
94 100
 
95 101
               return {
96
-                shape,
102
+                id: shape.id,
97 103
                 test: (brushBounds: Bounds) =>
98 104
                   boundsContained(bounds, brushBounds) ||
99 105
                   boundsCollide(bounds, brushBounds),
@@ -106,10 +112,11 @@ export default class BrushSession extends BaseSession {
106 112
               )
107 113
 
108 114
               return {
109
-                shape,
115
+                id: shape.id,
110 116
                 test: (brushBounds: Bounds) =>
111 117
                   boundsContained(bounds, brushBounds) ||
112
-                  intersectPolylineBounds(points, brushBounds).length > 0,
118
+                  (boundsCollide(bounds, brushBounds) &&
119
+                    intersectPolylineBounds(points, brushBounds).length > 0),
113 120
               }
114 121
             }
115 122
             default: {

+ 5
- 5
state/state.ts Voir le fichier

@@ -12,7 +12,7 @@ const initialData: Data = {
12 12
   },
13 13
   brush: undefined,
14 14
   pointedId: null,
15
-  selectedIds: [],
15
+  selectedIds: new Set([]),
16 16
   currentPageId: "page0",
17 17
   document: defaultDocument,
18 18
 }
@@ -61,7 +61,7 @@ const state = createState({
61 61
   },
62 62
   conditions: {
63 63
     isPointedShapeSelected(data) {
64
-      return data.selectedIds.includes(data.pointedId)
64
+      return data.selectedIds.has(data.pointedId)
65 65
     },
66 66
     isPressingShiftKey(data, payload: { shiftKey: boolean }) {
67 67
       return payload.shiftKey
@@ -93,14 +93,14 @@ const state = createState({
93 93
       data.pointedId = undefined
94 94
     },
95 95
     clearSelectedIds(data) {
96
-      data.selectedIds = []
96
+      data.selectedIds.clear()
97 97
     },
98 98
     pullPointedIdFromSelectedIds(data) {
99 99
       const { selectedIds, pointedId } = data
100
-      selectedIds.splice(selectedIds.indexOf(pointedId, 1))
100
+      selectedIds.delete(pointedId)
101 101
     },
102 102
     pushPointedIdToSelectedIds(data) {
103
-      data.selectedIds.push(data.pointedId)
103
+      data.selectedIds.add(data.pointedId)
104 104
     },
105 105
     // Camera
106 106
     zoomCamera(data, payload: { delta: number; point: number[] }) {

+ 2
- 0
styles/stitches.config.ts Voir le fichier

@@ -8,6 +8,8 @@ const { styled, global, css, theme, getCssString } = createCss({
8 8
     colors: {
9 9
       brushFill: "rgba(0,0,0,.1)",
10 10
       brushStroke: "rgba(0,0,0,.5)",
11
+      hint: "rgba(66, 133, 244, 0.200)",
12
+      selected: "rgba(66, 133, 244, 1.000)",
11 13
     },
12 14
     space: {},
13 15
     fontSizes: {

+ 4
- 2
types.ts Voir le fichier

@@ -5,7 +5,7 @@ export interface Data {
5 5
   }
6 6
   brush?: Bounds
7 7
   currentPageId: string
8
-  selectedIds: string[]
8
+  selectedIds: Set<string>
9 9
   pointedId?: string
10 10
   document: {
11 11
     pages: Record<string, Page>
@@ -121,4 +121,6 @@ export type ShapeSpecificProps<T extends Shape> = Pick<
121 121
 >
122 122
 
123 123
 export type ShapeProps<T extends Shape> = Partial<BaseShapeStyles> &
124
-  ShapeSpecificProps<T>
124
+  ShapeSpecificProps<T> & { id?: Shape["id"] }
125
+
126
+export type ShapeIndicatorProps<T extends Shape> = ShapeSpecificProps<T>

+ 5
- 0
utils/utils.ts Voir le fichier

@@ -848,3 +848,8 @@ export function getPointerEventInfo(e: React.PointerEvent | WheelEvent) {
848 848
   const { shiftKey, ctrlKey, metaKey, altKey } = e
849 849
   return { point: [e.clientX, e.clientY], shiftKey, ctrlKey, metaKey, altKey }
850 850
 }
851
+
852
+export function getKeyboardEventInfo(e: React.KeyboardEvent | KeyboardEvent) {
853
+  const { shiftKey, ctrlKey, metaKey, altKey } = e
854
+  return { key: e.key, shiftKey, ctrlKey, metaKey, altKey }
855
+}

Chargement…
Annuler
Enregistrer