Explorar el Código

[feature] grid cloning (#152)

* Adds more clone buttons

* Adds grid session, fix bug on text, adds keyboard handlers for sessions

* Adds copy paint, point argument to duplicate

* Adds tests for duplicate at point

* Adds status for shape cloning

* Adds 32px padding when clone brushing
main
Steve Ruiz hace 4 años
padre
commit
32b2ae88ee
No account linked to committer's email address

+ 22
- 3
packages/core/src/components/bounds/clone-button.tsx Ver fichero

@@ -4,12 +4,31 @@ import type { TLBounds } from '+types'
4 4
 
5 5
 export interface CloneButtonProps {
6 6
   bounds: TLBounds
7
-  side: 'top' | 'right' | 'bottom' | 'left'
7
+  side: 'top' | 'right' | 'bottom' | 'left' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'
8 8
 }
9 9
 
10 10
 export function CloneButton({ bounds, side }: CloneButtonProps) {
11
-  const x = side === 'left' ? -44 : side === 'right' ? bounds.width + 44 : bounds.width / 2
12
-  const y = side === 'top' ? -44 : side === 'bottom' ? bounds.height + 44 : bounds.height / 2
11
+  const x = {
12
+    left: -44,
13
+    topLeft: -44,
14
+    bottomLeft: -44,
15
+    right: bounds.width + 44,
16
+    topRight: bounds.width + 44,
17
+    bottomRight: bounds.width + 44,
18
+    top: bounds.width / 2,
19
+    bottom: bounds.width / 2,
20
+  }[side]
21
+
22
+  const y = {
23
+    left: bounds.height / 2,
24
+    right: bounds.height / 2,
25
+    top: -44,
26
+    topLeft: -44,
27
+    topRight: -44,
28
+    bottom: bounds.height + 44,
29
+    bottomLeft: bounds.height + 44,
30
+    bottomRight: bounds.height + 44,
31
+  }[side]
13 32
 
14 33
   const { callbacks, inputs } = useTLContext()
15 34
 

+ 4
- 0
packages/core/src/components/bounds/clone-buttons.tsx Ver fichero

@@ -13,6 +13,10 @@ export function CloneButtons({ bounds }: CloneButtonsProps) {
13 13
       <CloneButton bounds={bounds} side="right" />
14 14
       <CloneButton bounds={bounds} side="bottom" />
15 15
       <CloneButton bounds={bounds} side="left" />
16
+      <CloneButton bounds={bounds} side="topLeft" />
17
+      <CloneButton bounds={bounds} side="topRight" />
18
+      <CloneButton bounds={bounds} side="bottomLeft" />
19
+      <CloneButton bounds={bounds} side="bottomRight" />
16 20
     </>
17 21
   )
18 22
 }

+ 1
- 1
packages/tldraw/src/shape/shapes/sticky/sticky.tsx Ver fichero

@@ -163,7 +163,7 @@ export const Sticky = new ShapeUtil<StickyShape, HTMLDivElement, TLDrawMeta>(()
163 163
         onShapeChange?.({ id: shape.id, size: [size[0], MIN_CONTAINER_HEIGHT] })
164 164
         return
165 165
       }
166
-    }, [shape.text, shape.size[1]])
166
+    }, [shape.text, shape.size[1], shape.style])
167 167
 
168 168
     const style = {
169 169
       font,

+ 30
- 0
packages/tldraw/src/state/command/duplicate/duplicate.command.spec.ts Ver fichero

@@ -1,5 +1,7 @@
1 1
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
2
+import Utils from '~../../core/src/utils'
2 3
 import { TLDrawState } from '~state'
4
+import { TLDR } from '~state/tldr'
3 5
 import { mockDocument } from '~test'
4 6
 import { ArrowShape, SessionType, TLDrawShapeType } from '~types'
5 7
 
@@ -173,3 +175,31 @@ describe('Duplicate command', () => {
173 175
 
174 176
   it.todo('Does not delete uneffected bindings.')
175 177
 })
178
+
179
+describe('when point-duplicating', () => {
180
+  it('duplicates without crashing', () => {
181
+    const tlstate = new TLDrawState()
182
+
183
+    tlstate
184
+      .loadDocument(mockDocument)
185
+      .group(['rect1', 'rect2'])
186
+      .selectAll()
187
+      .duplicate(tlstate.selectedIds, [200, 200])
188
+  })
189
+
190
+  it('duplicates in the correct place', () => {
191
+    const tlstate = new TLDrawState()
192
+
193
+    tlstate.loadDocument(mockDocument).group(['rect1', 'rect2']).selectAll()
194
+
195
+    const before = tlstate.shapes.map((shape) => shape.id)
196
+
197
+    tlstate.duplicate(tlstate.selectedIds, [200, 200])
198
+
199
+    const after = tlstate.shapes.filter((shape) => !before.includes(shape.id))
200
+
201
+    expect(
202
+      Utils.getBoundsCenter(Utils.getCommonBounds(after.map((shape) => TLDR.getBounds(shape))))
203
+    ).toStrictEqual([200, 200])
204
+  })
205
+})

+ 24
- 6
packages/tldraw/src/state/command/duplicate/duplicate.command.ts Ver fichero

@@ -2,15 +2,13 @@
2 2
 import { Utils } from '@tldraw/core'
3 3
 import { Vec } from '@tldraw/vec'
4 4
 import { TLDR } from '~state/tldr'
5
-import type { Data, PagePartial, TLDrawCommand } from '~types'
5
+import type { Data, PagePartial, TLDrawCommand, TLDrawShape } from '~types'
6 6
 
7
-export function duplicate(data: Data, ids: string[]): TLDrawCommand {
7
+export function duplicate(data: Data, ids: string[], point?: number[]): TLDrawCommand {
8 8
   const { currentPageId } = data.appState
9 9
 
10 10
   const page = TLDR.getPage(data, currentPageId)
11 11
 
12
-  const delta = Vec.div([16, 16], TLDR.getCamera(data, currentPageId).zoom)
13
-
14 12
   const before: PagePartial = {
15 13
     shapes: {},
16 14
     bindings: {},
@@ -37,7 +35,6 @@ export function duplicate(data: Data, ids: string[]): TLDrawCommand {
37 35
       after.shapes[duplicatedId] = {
38 36
         ...Utils.deepClone(shape),
39 37
         id: duplicatedId,
40
-        point: Vec.round(Vec.add(shape.point, delta)),
41 38
         childIndex: TLDR.getChildIndexAbove(data, shape.id, currentPageId),
42 39
       }
43 40
 
@@ -74,7 +71,6 @@ export function duplicate(data: Data, ids: string[]): TLDrawCommand {
74 71
           ...Utils.deepClone(child),
75 72
           id: duplicatedId,
76 73
           parentId: duplicatedParentId,
77
-          point: Vec.round(Vec.add(child.point, delta)),
78 74
           childIndex: TLDR.getChildIndexAbove(data, child.id, currentPageId),
79 75
         }
80 76
         duplicateMap[childId] = duplicatedId
@@ -127,6 +123,28 @@ export function duplicate(data: Data, ids: string[]): TLDrawCommand {
127 123
       }
128 124
     })
129 125
 
126
+  // Now move the shapes
127
+
128
+  const shapesToMove = Object.values(after.shapes) as TLDrawShape[]
129
+
130
+  if (point) {
131
+    const commonBounds = Utils.getCommonBounds(shapesToMove.map((shape) => TLDR.getBounds(shape)))
132
+    const center = Utils.getBoundsCenter(commonBounds)
133
+    shapesToMove.forEach((shape) => {
134
+      // Could be a group
135
+      if (!shape.point) return
136
+
137
+      shape.point = Vec.sub(point, Vec.sub(center, shape.point))
138
+    })
139
+  } else {
140
+    const offset = [16, 16] // Vec.div([16, 16], data.document.pageStates[page.id].camera.zoom)
141
+    shapesToMove.forEach((shape) => {
142
+      // Could be a group
143
+      if (!shape.point) return
144
+      shape.point = Vec.add(shape.point, offset)
145
+    })
146
+  }
147
+
130 148
   return {
131 149
     id: 'duplicate',
132 150
     before: {

+ 9
- 4
packages/tldraw/src/state/session/sessions/arrow/arrow.session.ts Ver fichero

@@ -238,7 +238,14 @@ export class ArrowSession implements Session {
238 238
   }
239 239
 
240 240
   cancel = (data: Data) => {
241
-    const { initialShape, newBindingId } = this
241
+    const { initialShape, initialBinding, newBindingId } = this
242
+
243
+    const afterBindings: Record<string, TLDrawBinding | undefined> = {}
244
+
245
+    afterBindings[newBindingId] = undefined
246
+    if (initialBinding) {
247
+      afterBindings[initialBinding.id] = initialBinding
248
+    }
242 249
 
243 250
     return {
244 251
       document: {
@@ -247,9 +254,7 @@ export class ArrowSession implements Session {
247 254
             shapes: {
248 255
               [initialShape.id]: this.isCreate ? undefined : initialShape,
249 256
             },
250
-            bindings: {
251
-              [newBindingId]: undefined,
252
-            },
257
+            bindings: afterBindings,
253 258
           },
254 259
         },
255 260
         pageStates: {

+ 119
- 96
packages/tldraw/src/state/session/sessions/grid/grid.session.ts Ver fichero

@@ -19,116 +19,127 @@ import type { Patch } from 'rko'
19 19
 export class GridSession implements Session {
20 20
   type = SessionType.Grid
21 21
   status = TLDrawStatus.Translating
22
-  delta = [0, 0]
23
-  prev = [0, 0]
24 22
   origin: number[]
25 23
   shape: TLDrawShape
26
-  isCloning = false
27
-  clones: TLDrawShape[] = []
28 24
   bounds: TLBounds
29 25
   initialSelectedIds: string[]
30
-  grid: string[][]
26
+  initialSiblings?: string[]
27
+  grid: Record<string, string> = {}
31 28
   columns = 1
32 29
   rows = 1
30
+  isCopying = false
33 31
 
34 32
   constructor(data: Data, id: string, pageId: string, point: number[]) {
35 33
     this.origin = point
36 34
     this.shape = TLDR.getShape(data, id, pageId)
37
-    this.grid = [[this.shape.id]]
35
+    this.grid['0_0'] = this.shape.id
38 36
     this.bounds = TLDR.getBounds(this.shape)
39 37
     this.initialSelectedIds = TLDR.getSelectedIds(data, pageId)
38
+    if (this.shape.parentId !== pageId) {
39
+      this.initialSiblings = TLDR.getShape(data, this.shape.parentId, pageId).children?.filter(
40
+        (id) => id !== this.shape.id
41
+      )
42
+    }
40 43
   }
41 44
 
42 45
   start = () => void null
43 46
 
44
-  getClone = (point: number[]) => {
47
+  getClone = (point: number[], copy: boolean) => {
45 48
     const clone = {
46 49
       ...this.shape,
47 50
       id: Utils.uniqueId(),
48 51
       point,
49 52
     }
50
-    if (clone.type === TLDrawShapeType.Sticky) {
51
-      clone.text = ''
53
+
54
+    if (!copy) {
55
+      if (clone.type === TLDrawShapeType.Sticky) {
56
+        clone.text = ''
57
+      }
52 58
     }
59
+
53 60
     return clone
54 61
   }
55 62
 
56
-  update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean) => {
57
-    const { currentPageId } = data.appState
58
-
63
+  update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean, metaKey: boolean) => {
59 64
     const nextShapes: Patch<Record<string, TLDrawShape>> = {}
60 65
 
61 66
     const nextPageState: Patch<TLPageState> = {}
62 67
 
63
-    const delta = Vec.sub(point, this.origin)
68
+    const center = Utils.getBoundsCenter(this.bounds)
69
+
70
+    const offset = Vec.sub(point, center)
64 71
 
65 72
     if (shiftKey) {
66
-      if (Math.abs(delta[0]) < Math.abs(delta[1])) {
67
-        delta[0] = 0
73
+      if (Math.abs(offset[0]) < Math.abs(offset[1])) {
74
+        offset[0] = 0
68 75
       } else {
69
-        delta[1] = 0
76
+        offset[1] = 0
70 77
       }
71 78
     }
79
+    // use the distance from center to determine the grid
72 80
 
73
-    this.delta = delta
74
-
75
-    this.prev = delta
76
-
77
-    const startX = this.shape.point[0]
78
-    const startY = this.shape.point[1]
79 81
     const gapX = this.bounds.width + 32
80 82
     const gapY = this.bounds.height + 32
81 83
 
82
-    const columns = Math.max(
83
-      1,
84
-      Math.floor(Math.abs(this.delta[0] + this.bounds.width / 2) / gapX + 1)
85
-    )
86
-
87
-    const rows = Math.max(
88
-      1,
89
-      Math.floor(Math.abs(this.delta[1] + this.bounds.height / 2) / gapY + 1)
90
-    )
91
-
92
-    console.log(rows, columns)
93
-
94
-    // if (columns > this.columns) {
95
-    //   for (let x = this.columns; x < columns; x++) {
96
-    //     this.grid.forEach((row, y) => {
97
-    //       const clone = this.getClone([startX + x * gapX, startY + y * gapY])
98
-    //       row.push(clone.id)
99
-    //       nextShapes[clone.id] = clone
100
-    //     })
101
-    //   }
102
-    // } else if (columns < this.columns) {
103
-    //   this.grid.forEach((row) => {
104
-    //     for (let x = this.columns; x > columns; x--) {
105
-    //       const id = row.pop()
106
-    //       if (id) nextShapes[id] = undefined
107
-    //     }
108
-    //   })
109
-    // }
110
-
111
-    // this.columns = columns
112
-
113
-    // if (rows > this.rows) {
114
-    //   for (let y = this.rows; y < rows; y++) {
115
-    //     const row: string[] = []
116
-    //     for (let x = 0; x < this.columns; x++) {
117
-    //       const clone = this.getClone([startX + x * gapX, startY + y * gapY])
118
-    //       row.push(clone.id)
119
-    //       nextShapes[clone.id] = clone
120
-    //     }
121
-    //     this.grid.push(row)
122
-    //   }
123
-    // } else if (rows < this.rows) {
124
-    //   for (let y = this.rows; y > rows; y--) {
125
-    //     const row = this.grid[y - 1]
126
-    //     row.forEach((id) => (nextShapes[id] = undefined))
127
-    //     this.grid.pop()
128
-    //   }
129
-    // }
130
-
131
-    // this.rows = rows
84
+    const columns = Math.ceil(offset[0] / gapX)
85
+    const rows = Math.ceil(offset[1] / gapY)
86
+
87
+    const minX = Math.min(columns, 0)
88
+    const minY = Math.min(rows, 0)
89
+    const maxX = Math.max(columns, 1)
90
+    const maxY = Math.max(rows, 1)
91
+
92
+    const inGrid = new Set<string>()
93
+
94
+    const isCopying = altKey
95
+
96
+    if (isCopying !== this.isCopying) {
97
+      // Recreate shapes copying
98
+      Object.values(this.grid)
99
+        .filter((id) => id !== this.shape.id)
100
+        .forEach((id) => (nextShapes[id] = undefined))
101
+
102
+      this.grid = { '0_0': this.shape.id }
103
+
104
+      this.isCopying = isCopying
105
+    }
106
+
107
+    // Go through grid, adding items in positions
108
+    // that aren't already filled.
109
+    for (let x = minX; x < maxX; x++) {
110
+      for (let y = minY; y < maxY; y++) {
111
+        const position = `${x}_${y}`
112
+
113
+        inGrid.add(position)
114
+
115
+        if (this.grid[position]) continue
116
+
117
+        if (x === 0 && y === 0) continue
118
+
119
+        const clone = this.getClone(Vec.add(this.shape.point, [x * gapX, y * gapY]), isCopying)
120
+
121
+        nextShapes[clone.id] = clone
122
+
123
+        this.grid[position] = clone.id
124
+      }
125
+    }
126
+
127
+    // Remove any other items from the grid
128
+    Object.entries(this.grid).forEach(([position, id]) => {
129
+      if (!inGrid.has(position)) {
130
+        nextShapes[id] = undefined
131
+        delete this.grid[position]
132
+      }
133
+    })
134
+
135
+    if (Object.values(nextShapes).length === 0) return
136
+
137
+    // Add shapes to parent id
138
+    if (this.initialSiblings) {
139
+      nextShapes[this.shape.parentId] = {
140
+        children: [...this.initialSiblings, ...Object.values(this.grid)],
141
+      }
142
+    }
132 143
 
133 144
     return {
134 145
       document: {
@@ -145,39 +156,40 @@ export class GridSession implements Session {
145 156
   }
146 157
 
147 158
   cancel = (data: Data) => {
148
-    const nextBindings: Record<string, Partial<TLDrawBinding> | undefined> = {}
149 159
     const nextShapes: Record<string, Partial<TLDrawShape> | undefined> = {}
150
-    const nextPageState: Partial<TLPageState> = {}
151
-
152
-    // Put initial shapes back to where they started
153
-    nextShapes[this.shape.id] = { ...nextShapes[this.shape.id], point: this.shape.point }
154 160
 
155 161
     // Delete clones
156
-    this.grid.forEach((row) =>
157
-      row.forEach((id) => {
158
-        nextShapes[id] = undefined
159
-        // TODO: Remove shape from parent if grouped
160
-      })
161
-    )
162
+    Object.values(this.grid).forEach((id) => {
163
+      nextShapes[id] = undefined
164
+      // TODO: Remove from parent if grouped
165
+    })
162 166
 
163
-    nextPageState.selectedIds = [this.shape.id]
167
+    // Put back the initial shape
168
+    nextShapes[this.shape.id] = { ...nextShapes[this.shape.id], point: this.shape.point }
169
+
170
+    if (this.initialSiblings) {
171
+      nextShapes[this.shape.parentId] = {
172
+        children: [...this.initialSiblings, this.shape.id],
173
+      }
174
+    }
164 175
 
165 176
     return {
166 177
       document: {
167 178
         pages: {
168 179
           [data.appState.currentPageId]: {
169 180
             shapes: nextShapes,
170
-            bindings: nextBindings,
171 181
           },
172 182
         },
173 183
         pageStates: {
174
-          [data.appState.currentPageId]: nextPageState,
184
+          [data.appState.currentPageId]: {
185
+            selectedIds: [this.shape.id],
186
+          },
175 187
         },
176 188
       },
177 189
     }
178 190
   }
179 191
 
180
-  complete(data: Data): TLDrawCommand {
192
+  complete(data: Data) {
181 193
     const pageId = data.appState.currentPageId
182 194
 
183 195
     const beforeShapes: Patch<Record<string, TLDrawShape>> = {}
@@ -186,17 +198,28 @@ export class GridSession implements Session {
186 198
 
187 199
     const afterSelectedIds: string[] = []
188 200
 
189
-    this.grid.forEach((row) =>
190
-      row.forEach((id) => {
191
-        beforeShapes[id] = undefined
192
-        afterShapes[id] = TLDR.getShape(data, id, pageId)
193
-        afterSelectedIds.push(id)
194
-        // TODO: Add shape to parent if grouped
195
-      })
196
-    )
201
+    Object.values(this.grid).forEach((id) => {
202
+      beforeShapes[id] = undefined
203
+      afterShapes[id] = TLDR.getShape(data, id, pageId)
204
+      afterSelectedIds.push(id)
205
+      // TODO: Add shape to parent if grouped
206
+    })
197 207
 
198 208
     beforeShapes[this.shape.id] = this.shape
199
-    afterShapes[this.shape.id] = this.shape
209
+
210
+    // Add shapes to parent id
211
+    if (this.initialSiblings) {
212
+      beforeShapes[this.shape.parentId] = {
213
+        children: [...this.initialSiblings, this.shape.id],
214
+      }
215
+
216
+      afterShapes[this.shape.parentId] = {
217
+        children: [...this.initialSiblings, ...Object.values(this.grid)],
218
+      }
219
+    }
220
+
221
+    // If no new shapes have been created, bail
222
+    if (afterSelectedIds.length === 1) return
200 223
 
201 224
     return {
202 225
       id: 'grid',

+ 3
- 33
packages/tldraw/src/state/tlstate.ts Ver fichero

@@ -1660,40 +1660,10 @@ export class TLDrawState extends StateManager<Data> {
1660 1660
     if (!session) return this
1661 1661
     this.session = undefined
1662 1662
 
1663
-    if (this.status === 'creating') {
1664
-      return this.patchState(
1665
-        {
1666
-          document: {
1667
-            pages: {
1668
-              [this.currentPageId]: {
1669
-                shapes: {
1670
-                  ...Object.fromEntries(this.selectedIds.map((id) => [id, undefined])),
1671
-                },
1672
-              },
1673
-            },
1674
-            pageStates: {
1675
-              [this.currentPageId]: {
1676
-                selectedIds: [],
1677
-                editingId: undefined,
1678
-                bindingId: undefined,
1679
-                hoveredId: undefined,
1680
-              },
1681
-            },
1682
-          },
1683
-        },
1684
-        `session:cancel_create:${session.constructor.name}`
1685
-      )
1686
-    }
1687
-
1688 1663
     const result = session.cancel(this.state)
1689 1664
 
1690 1665
     if (result) {
1691
-      this.patchState(
1692
-        {
1693
-          ...session.cancel(this.state),
1694
-        },
1695
-        `session:cancel:${session.constructor.name}`
1696
-      )
1666
+      this.patchState(result, `session:cancel:${session.constructor.name}`)
1697 1667
     }
1698 1668
 
1699 1669
     return this
@@ -2021,9 +1991,9 @@ export class TLDrawState extends StateManager<Data> {
2021 1991
    * Duplicate one or more shapes.
2022 1992
    * @param ids The ids to duplicate (defaults to selection).
2023 1993
    */
2024
-  duplicate = (ids = this.selectedIds): this => {
1994
+  duplicate = (ids = this.selectedIds, point?: number[]): this => {
2025 1995
     if (ids.length === 0) return this
2026
-    return this.setState(Commands.duplicate(this.state, ids))
1996
+    return this.setState(Commands.duplicate(this.state, ids, point))
2027 1997
   }
2028 1998
 
2029 1999
   /**

+ 28
- 4
packages/tldraw/src/state/tool/BaseTool/BaseTool.ts Ver fichero

@@ -52,10 +52,6 @@ export abstract class BaseTool {
52 52
     }
53 53
   }
54 54
 
55
-  // Keyboard events
56
-  onKeyDown?: TLKeyboardEventHandler
57
-  onKeyUp?: TLKeyboardEventHandler
58
-
59 55
   // Camera Events
60 56
   onPan?: TLWheelEventHandler
61 57
   onZoom?: TLWheelEventHandler
@@ -131,4 +127,32 @@ export abstract class BaseTool {
131 127
     this.state.pinchZoom(info.point, info.delta, info.delta[2])
132 128
     this.onPointerMove?.(info, e as unknown as React.PointerEvent)
133 129
   }
130
+
131
+  /* ---------------------- Keys ---------------------- */
132
+
133
+  onKeyDown: TLKeyboardEventHandler = (key, info) => {
134
+    /* noop */
135
+    if (key === 'Meta' || key === 'Control' || key === 'Alt') {
136
+      this.state.updateSession(
137
+        this.state.getPagePoint(info.point),
138
+        info.shiftKey,
139
+        info.altKey,
140
+        info.metaKey
141
+      )
142
+      return
143
+    }
144
+  }
145
+
146
+  onKeyUp: TLKeyboardEventHandler = (key, info) => {
147
+    /* noop */
148
+    if (key === 'Meta' || key === 'Control' || key === 'Alt') {
149
+      this.state.updateSession(
150
+        this.state.getPagePoint(info.point),
151
+        info.shiftKey,
152
+        info.altKey,
153
+        info.metaKey
154
+      )
155
+      return
156
+    }
157
+  }
134 158
 }

+ 33
- 6
packages/tldraw/src/state/tool/SelectTool/SelectTool.ts Ver fichero

@@ -29,6 +29,7 @@ enum Status {
29 29
   Pinching = 'pinching',
30 30
   Brushing = 'brushing',
31 31
   GridCloning = 'gridCloning',
32
+  ClonePainting = 'clonePainting',
32 33
 }
33 34
 
34 35
 export class SelectTool extends BaseTool {
@@ -162,8 +163,7 @@ export class SelectTool extends BaseTool {
162 163
       return
163 164
     }
164 165
 
165
-    if (key === 'Meta' || key === 'Control') {
166
-      // TODO: Make all sessions have all of these arguments
166
+    if (key === 'Meta' || key === 'Control' || key === 'Alt') {
167 167
       this.state.updateSession(
168 168
         this.state.getPagePoint(info.point),
169 169
         info.shiftKey,
@@ -174,9 +174,7 @@ export class SelectTool extends BaseTool {
174 174
     }
175 175
   }
176 176
 
177
-  onKeyUp: TLKeyboardEventHandler = () => {
178
-    /* noop */
179
-  }
177
+  // Keyup is handled on BaseTool
180 178
 
181 179
   // Pointer Events (generic)
182 180
 
@@ -263,6 +261,29 @@ export class SelectTool extends BaseTool {
263 261
       return
264 262
     }
265 263
 
264
+    const { shapes, selectedIds, getShapeBounds } = this.state
265
+
266
+    if (info.shiftKey && info.altKey && selectedIds.length > 0) {
267
+      const point = this.state.getPagePoint(info.point)
268
+      const bounds = Utils.expandBounds(
269
+        Utils.getCommonBounds(selectedIds.map((id) => getShapeBounds(id))),
270
+        32
271
+      )
272
+      const centeredBounds = Utils.centerBounds(bounds, point)
273
+
274
+      if (!shapes.some((shape) => TLDR.getShapeUtils(shape).hitTestBounds(shape, centeredBounds))) {
275
+        this.state.duplicate(this.state.selectedIds, point)
276
+      }
277
+
278
+      if (this.status === Status.Idle) {
279
+        this.setStatus(Status.ClonePainting)
280
+      }
281
+
282
+      return
283
+    } else if (this.status === Status.ClonePainting) {
284
+      this.setStatus(Status.Idle)
285
+    }
286
+
266 287
     if (this.state.session) {
267 288
       return this.state.updateSession(
268 289
         this.state.getPagePoint(info.point),
@@ -335,10 +356,16 @@ export class SelectTool extends BaseTool {
335 356
   onPointCanvas: TLCanvasEventHandler = (info) => {
336 357
     // Unless the user is holding shift or meta, clear the current selection
337 358
     if (!info.shiftKey) {
338
-      this.deselectAll()
339 359
       if (this.state.pageState.editingId) {
340 360
         this.state.setEditingId()
341 361
       }
362
+
363
+      if (info.altKey && this.state.selectedIds.length > 0) {
364
+        this.state.duplicate(this.state.selectedIds, this.state.getPagePoint(info.point))
365
+        return
366
+      }
367
+
368
+      this.deselectAll()
342 369
     }
343 370
 
344 371
     this.setStatus(Status.PointingCanvas)

+ 4
- 0
packages/tldraw/src/state/tool/TextTool/TextTool.ts Ver fichero

@@ -68,6 +68,10 @@ export class TextTool extends BaseTool {
68 68
 
69 69
   /* ----------------- Event Handlers ----------------- */
70 70
 
71
+  onKeyUp = () => void null
72
+
73
+  onKeyDown = () => void null
74
+
71 75
   onPointerDown: TLPointerEventHandler = (info) => {
72 76
     if (this.status === Status.Idle) {
73 77
       const pagePoint = Vec.round(this.state.getPagePoint(info.point))

Loading…
Cancelar
Guardar