[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
This commit is contained in:
parent
0d8d45d873
commit
32b2ae88ee
11 changed files with 271 additions and 147 deletions
|
@ -4,12 +4,31 @@ import type { TLBounds } from '+types'
|
||||||
|
|
||||||
export interface CloneButtonProps {
|
export interface CloneButtonProps {
|
||||||
bounds: TLBounds
|
bounds: TLBounds
|
||||||
side: 'top' | 'right' | 'bottom' | 'left'
|
side: 'top' | 'right' | 'bottom' | 'left' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CloneButton({ bounds, side }: CloneButtonProps) {
|
export function CloneButton({ bounds, side }: CloneButtonProps) {
|
||||||
const x = side === 'left' ? -44 : side === 'right' ? bounds.width + 44 : bounds.width / 2
|
const x = {
|
||||||
const y = side === 'top' ? -44 : side === 'bottom' ? bounds.height + 44 : bounds.height / 2
|
left: -44,
|
||||||
|
topLeft: -44,
|
||||||
|
bottomLeft: -44,
|
||||||
|
right: bounds.width + 44,
|
||||||
|
topRight: bounds.width + 44,
|
||||||
|
bottomRight: bounds.width + 44,
|
||||||
|
top: bounds.width / 2,
|
||||||
|
bottom: bounds.width / 2,
|
||||||
|
}[side]
|
||||||
|
|
||||||
|
const y = {
|
||||||
|
left: bounds.height / 2,
|
||||||
|
right: bounds.height / 2,
|
||||||
|
top: -44,
|
||||||
|
topLeft: -44,
|
||||||
|
topRight: -44,
|
||||||
|
bottom: bounds.height + 44,
|
||||||
|
bottomLeft: bounds.height + 44,
|
||||||
|
bottomRight: bounds.height + 44,
|
||||||
|
}[side]
|
||||||
|
|
||||||
const { callbacks, inputs } = useTLContext()
|
const { callbacks, inputs } = useTLContext()
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,10 @@ export function CloneButtons({ bounds }: CloneButtonsProps) {
|
||||||
<CloneButton bounds={bounds} side="right" />
|
<CloneButton bounds={bounds} side="right" />
|
||||||
<CloneButton bounds={bounds} side="bottom" />
|
<CloneButton bounds={bounds} side="bottom" />
|
||||||
<CloneButton bounds={bounds} side="left" />
|
<CloneButton bounds={bounds} side="left" />
|
||||||
|
<CloneButton bounds={bounds} side="topLeft" />
|
||||||
|
<CloneButton bounds={bounds} side="topRight" />
|
||||||
|
<CloneButton bounds={bounds} side="bottomLeft" />
|
||||||
|
<CloneButton bounds={bounds} side="bottomRight" />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -163,7 +163,7 @@ export const Sticky = new ShapeUtil<StickyShape, HTMLDivElement, TLDrawMeta>(()
|
||||||
onShapeChange?.({ id: shape.id, size: [size[0], MIN_CONTAINER_HEIGHT] })
|
onShapeChange?.({ id: shape.id, size: [size[0], MIN_CONTAINER_HEIGHT] })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [shape.text, shape.size[1]])
|
}, [shape.text, shape.size[1], shape.style])
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
font,
|
font,
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import Utils from '~../../core/src/utils'
|
||||||
import { TLDrawState } from '~state'
|
import { TLDrawState } from '~state'
|
||||||
|
import { TLDR } from '~state/tldr'
|
||||||
import { mockDocument } from '~test'
|
import { mockDocument } from '~test'
|
||||||
import { ArrowShape, SessionType, TLDrawShapeType } from '~types'
|
import { ArrowShape, SessionType, TLDrawShapeType } from '~types'
|
||||||
|
|
||||||
|
@ -173,3 +175,31 @@ describe('Duplicate command', () => {
|
||||||
|
|
||||||
it.todo('Does not delete uneffected bindings.')
|
it.todo('Does not delete uneffected bindings.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('when point-duplicating', () => {
|
||||||
|
it('duplicates without crashing', () => {
|
||||||
|
const tlstate = new TLDrawState()
|
||||||
|
|
||||||
|
tlstate
|
||||||
|
.loadDocument(mockDocument)
|
||||||
|
.group(['rect1', 'rect2'])
|
||||||
|
.selectAll()
|
||||||
|
.duplicate(tlstate.selectedIds, [200, 200])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('duplicates in the correct place', () => {
|
||||||
|
const tlstate = new TLDrawState()
|
||||||
|
|
||||||
|
tlstate.loadDocument(mockDocument).group(['rect1', 'rect2']).selectAll()
|
||||||
|
|
||||||
|
const before = tlstate.shapes.map((shape) => shape.id)
|
||||||
|
|
||||||
|
tlstate.duplicate(tlstate.selectedIds, [200, 200])
|
||||||
|
|
||||||
|
const after = tlstate.shapes.filter((shape) => !before.includes(shape.id))
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Utils.getBoundsCenter(Utils.getCommonBounds(after.map((shape) => TLDR.getBounds(shape))))
|
||||||
|
).toStrictEqual([200, 200])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -2,15 +2,13 @@
|
||||||
import { Utils } from '@tldraw/core'
|
import { Utils } from '@tldraw/core'
|
||||||
import { Vec } from '@tldraw/vec'
|
import { Vec } from '@tldraw/vec'
|
||||||
import { TLDR } from '~state/tldr'
|
import { TLDR } from '~state/tldr'
|
||||||
import type { Data, PagePartial, TLDrawCommand } from '~types'
|
import type { Data, PagePartial, TLDrawCommand, TLDrawShape } from '~types'
|
||||||
|
|
||||||
export function duplicate(data: Data, ids: string[]): TLDrawCommand {
|
export function duplicate(data: Data, ids: string[], point?: number[]): TLDrawCommand {
|
||||||
const { currentPageId } = data.appState
|
const { currentPageId } = data.appState
|
||||||
|
|
||||||
const page = TLDR.getPage(data, currentPageId)
|
const page = TLDR.getPage(data, currentPageId)
|
||||||
|
|
||||||
const delta = Vec.div([16, 16], TLDR.getCamera(data, currentPageId).zoom)
|
|
||||||
|
|
||||||
const before: PagePartial = {
|
const before: PagePartial = {
|
||||||
shapes: {},
|
shapes: {},
|
||||||
bindings: {},
|
bindings: {},
|
||||||
|
@ -37,7 +35,6 @@ export function duplicate(data: Data, ids: string[]): TLDrawCommand {
|
||||||
after.shapes[duplicatedId] = {
|
after.shapes[duplicatedId] = {
|
||||||
...Utils.deepClone(shape),
|
...Utils.deepClone(shape),
|
||||||
id: duplicatedId,
|
id: duplicatedId,
|
||||||
point: Vec.round(Vec.add(shape.point, delta)),
|
|
||||||
childIndex: TLDR.getChildIndexAbove(data, shape.id, currentPageId),
|
childIndex: TLDR.getChildIndexAbove(data, shape.id, currentPageId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,7 +71,6 @@ export function duplicate(data: Data, ids: string[]): TLDrawCommand {
|
||||||
...Utils.deepClone(child),
|
...Utils.deepClone(child),
|
||||||
id: duplicatedId,
|
id: duplicatedId,
|
||||||
parentId: duplicatedParentId,
|
parentId: duplicatedParentId,
|
||||||
point: Vec.round(Vec.add(child.point, delta)),
|
|
||||||
childIndex: TLDR.getChildIndexAbove(data, child.id, currentPageId),
|
childIndex: TLDR.getChildIndexAbove(data, child.id, currentPageId),
|
||||||
}
|
}
|
||||||
duplicateMap[childId] = duplicatedId
|
duplicateMap[childId] = duplicatedId
|
||||||
|
@ -127,6 +123,28 @@ export function duplicate(data: Data, ids: string[]): TLDrawCommand {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Now move the shapes
|
||||||
|
|
||||||
|
const shapesToMove = Object.values(after.shapes) as TLDrawShape[]
|
||||||
|
|
||||||
|
if (point) {
|
||||||
|
const commonBounds = Utils.getCommonBounds(shapesToMove.map((shape) => TLDR.getBounds(shape)))
|
||||||
|
const center = Utils.getBoundsCenter(commonBounds)
|
||||||
|
shapesToMove.forEach((shape) => {
|
||||||
|
// Could be a group
|
||||||
|
if (!shape.point) return
|
||||||
|
|
||||||
|
shape.point = Vec.sub(point, Vec.sub(center, shape.point))
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const offset = [16, 16] // Vec.div([16, 16], data.document.pageStates[page.id].camera.zoom)
|
||||||
|
shapesToMove.forEach((shape) => {
|
||||||
|
// Could be a group
|
||||||
|
if (!shape.point) return
|
||||||
|
shape.point = Vec.add(shape.point, offset)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: 'duplicate',
|
id: 'duplicate',
|
||||||
before: {
|
before: {
|
||||||
|
|
|
@ -238,7 +238,14 @@ export class ArrowSession implements Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel = (data: Data) => {
|
cancel = (data: Data) => {
|
||||||
const { initialShape, newBindingId } = this
|
const { initialShape, initialBinding, newBindingId } = this
|
||||||
|
|
||||||
|
const afterBindings: Record<string, TLDrawBinding | undefined> = {}
|
||||||
|
|
||||||
|
afterBindings[newBindingId] = undefined
|
||||||
|
if (initialBinding) {
|
||||||
|
afterBindings[initialBinding.id] = initialBinding
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
document: {
|
document: {
|
||||||
|
@ -247,9 +254,7 @@ export class ArrowSession implements Session {
|
||||||
shapes: {
|
shapes: {
|
||||||
[initialShape.id]: this.isCreate ? undefined : initialShape,
|
[initialShape.id]: this.isCreate ? undefined : initialShape,
|
||||||
},
|
},
|
||||||
bindings: {
|
bindings: afterBindings,
|
||||||
[newBindingId]: undefined,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pageStates: {
|
pageStates: {
|
||||||
|
|
|
@ -19,116 +19,127 @@ import type { Patch } from 'rko'
|
||||||
export class GridSession implements Session {
|
export class GridSession implements Session {
|
||||||
type = SessionType.Grid
|
type = SessionType.Grid
|
||||||
status = TLDrawStatus.Translating
|
status = TLDrawStatus.Translating
|
||||||
delta = [0, 0]
|
|
||||||
prev = [0, 0]
|
|
||||||
origin: number[]
|
origin: number[]
|
||||||
shape: TLDrawShape
|
shape: TLDrawShape
|
||||||
isCloning = false
|
|
||||||
clones: TLDrawShape[] = []
|
|
||||||
bounds: TLBounds
|
bounds: TLBounds
|
||||||
initialSelectedIds: string[]
|
initialSelectedIds: string[]
|
||||||
grid: string[][]
|
initialSiblings?: string[]
|
||||||
|
grid: Record<string, string> = {}
|
||||||
columns = 1
|
columns = 1
|
||||||
rows = 1
|
rows = 1
|
||||||
|
isCopying = false
|
||||||
|
|
||||||
constructor(data: Data, id: string, pageId: string, point: number[]) {
|
constructor(data: Data, id: string, pageId: string, point: number[]) {
|
||||||
this.origin = point
|
this.origin = point
|
||||||
this.shape = TLDR.getShape(data, id, pageId)
|
this.shape = TLDR.getShape(data, id, pageId)
|
||||||
this.grid = [[this.shape.id]]
|
this.grid['0_0'] = this.shape.id
|
||||||
this.bounds = TLDR.getBounds(this.shape)
|
this.bounds = TLDR.getBounds(this.shape)
|
||||||
this.initialSelectedIds = TLDR.getSelectedIds(data, pageId)
|
this.initialSelectedIds = TLDR.getSelectedIds(data, pageId)
|
||||||
|
if (this.shape.parentId !== pageId) {
|
||||||
|
this.initialSiblings = TLDR.getShape(data, this.shape.parentId, pageId).children?.filter(
|
||||||
|
(id) => id !== this.shape.id
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
start = () => void null
|
start = () => void null
|
||||||
|
|
||||||
getClone = (point: number[]) => {
|
getClone = (point: number[], copy: boolean) => {
|
||||||
const clone = {
|
const clone = {
|
||||||
...this.shape,
|
...this.shape,
|
||||||
id: Utils.uniqueId(),
|
id: Utils.uniqueId(),
|
||||||
point,
|
point,
|
||||||
}
|
}
|
||||||
if (clone.type === TLDrawShapeType.Sticky) {
|
|
||||||
clone.text = ''
|
if (!copy) {
|
||||||
|
if (clone.type === TLDrawShapeType.Sticky) {
|
||||||
|
clone.text = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean) => {
|
update = (data: Data, point: number[], shiftKey: boolean, altKey: boolean, metaKey: boolean) => {
|
||||||
const { currentPageId } = data.appState
|
|
||||||
|
|
||||||
const nextShapes: Patch<Record<string, TLDrawShape>> = {}
|
const nextShapes: Patch<Record<string, TLDrawShape>> = {}
|
||||||
|
|
||||||
const nextPageState: Patch<TLPageState> = {}
|
const nextPageState: Patch<TLPageState> = {}
|
||||||
|
|
||||||
const delta = Vec.sub(point, this.origin)
|
const center = Utils.getBoundsCenter(this.bounds)
|
||||||
|
|
||||||
|
const offset = Vec.sub(point, center)
|
||||||
|
|
||||||
if (shiftKey) {
|
if (shiftKey) {
|
||||||
if (Math.abs(delta[0]) < Math.abs(delta[1])) {
|
if (Math.abs(offset[0]) < Math.abs(offset[1])) {
|
||||||
delta[0] = 0
|
offset[0] = 0
|
||||||
} else {
|
} else {
|
||||||
delta[1] = 0
|
offset[1] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// use the distance from center to determine the grid
|
||||||
|
|
||||||
this.delta = delta
|
|
||||||
|
|
||||||
this.prev = delta
|
|
||||||
|
|
||||||
const startX = this.shape.point[0]
|
|
||||||
const startY = this.shape.point[1]
|
|
||||||
const gapX = this.bounds.width + 32
|
const gapX = this.bounds.width + 32
|
||||||
const gapY = this.bounds.height + 32
|
const gapY = this.bounds.height + 32
|
||||||
|
|
||||||
const columns = Math.max(
|
const columns = Math.ceil(offset[0] / gapX)
|
||||||
1,
|
const rows = Math.ceil(offset[1] / gapY)
|
||||||
Math.floor(Math.abs(this.delta[0] + this.bounds.width / 2) / gapX + 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
const rows = Math.max(
|
const minX = Math.min(columns, 0)
|
||||||
1,
|
const minY = Math.min(rows, 0)
|
||||||
Math.floor(Math.abs(this.delta[1] + this.bounds.height / 2) / gapY + 1)
|
const maxX = Math.max(columns, 1)
|
||||||
)
|
const maxY = Math.max(rows, 1)
|
||||||
|
|
||||||
console.log(rows, columns)
|
const inGrid = new Set<string>()
|
||||||
|
|
||||||
// if (columns > this.columns) {
|
const isCopying = altKey
|
||||||
// for (let x = this.columns; x < columns; x++) {
|
|
||||||
// this.grid.forEach((row, y) => {
|
|
||||||
// const clone = this.getClone([startX + x * gapX, startY + y * gapY])
|
|
||||||
// row.push(clone.id)
|
|
||||||
// nextShapes[clone.id] = clone
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
// } else if (columns < this.columns) {
|
|
||||||
// this.grid.forEach((row) => {
|
|
||||||
// for (let x = this.columns; x > columns; x--) {
|
|
||||||
// const id = row.pop()
|
|
||||||
// if (id) nextShapes[id] = undefined
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
|
|
||||||
// this.columns = columns
|
if (isCopying !== this.isCopying) {
|
||||||
|
// Recreate shapes copying
|
||||||
|
Object.values(this.grid)
|
||||||
|
.filter((id) => id !== this.shape.id)
|
||||||
|
.forEach((id) => (nextShapes[id] = undefined))
|
||||||
|
|
||||||
// if (rows > this.rows) {
|
this.grid = { '0_0': this.shape.id }
|
||||||
// for (let y = this.rows; y < rows; y++) {
|
|
||||||
// const row: string[] = []
|
|
||||||
// for (let x = 0; x < this.columns; x++) {
|
|
||||||
// const clone = this.getClone([startX + x * gapX, startY + y * gapY])
|
|
||||||
// row.push(clone.id)
|
|
||||||
// nextShapes[clone.id] = clone
|
|
||||||
// }
|
|
||||||
// this.grid.push(row)
|
|
||||||
// }
|
|
||||||
// } else if (rows < this.rows) {
|
|
||||||
// for (let y = this.rows; y > rows; y--) {
|
|
||||||
// const row = this.grid[y - 1]
|
|
||||||
// row.forEach((id) => (nextShapes[id] = undefined))
|
|
||||||
// this.grid.pop()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// this.rows = rows
|
this.isCopying = isCopying
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go through grid, adding items in positions
|
||||||
|
// that aren't already filled.
|
||||||
|
for (let x = minX; x < maxX; x++) {
|
||||||
|
for (let y = minY; y < maxY; y++) {
|
||||||
|
const position = `${x}_${y}`
|
||||||
|
|
||||||
|
inGrid.add(position)
|
||||||
|
|
||||||
|
if (this.grid[position]) continue
|
||||||
|
|
||||||
|
if (x === 0 && y === 0) continue
|
||||||
|
|
||||||
|
const clone = this.getClone(Vec.add(this.shape.point, [x * gapX, y * gapY]), isCopying)
|
||||||
|
|
||||||
|
nextShapes[clone.id] = clone
|
||||||
|
|
||||||
|
this.grid[position] = clone.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any other items from the grid
|
||||||
|
Object.entries(this.grid).forEach(([position, id]) => {
|
||||||
|
if (!inGrid.has(position)) {
|
||||||
|
nextShapes[id] = undefined
|
||||||
|
delete this.grid[position]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Object.values(nextShapes).length === 0) return
|
||||||
|
|
||||||
|
// Add shapes to parent id
|
||||||
|
if (this.initialSiblings) {
|
||||||
|
nextShapes[this.shape.parentId] = {
|
||||||
|
children: [...this.initialSiblings, ...Object.values(this.grid)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
document: {
|
document: {
|
||||||
|
@ -145,39 +156,40 @@ export class GridSession implements Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel = (data: Data) => {
|
cancel = (data: Data) => {
|
||||||
const nextBindings: Record<string, Partial<TLDrawBinding> | undefined> = {}
|
|
||||||
const nextShapes: Record<string, Partial<TLDrawShape> | undefined> = {}
|
const nextShapes: Record<string, Partial<TLDrawShape> | undefined> = {}
|
||||||
const nextPageState: Partial<TLPageState> = {}
|
|
||||||
|
|
||||||
// Put initial shapes back to where they started
|
|
||||||
nextShapes[this.shape.id] = { ...nextShapes[this.shape.id], point: this.shape.point }
|
|
||||||
|
|
||||||
// Delete clones
|
// Delete clones
|
||||||
this.grid.forEach((row) =>
|
Object.values(this.grid).forEach((id) => {
|
||||||
row.forEach((id) => {
|
nextShapes[id] = undefined
|
||||||
nextShapes[id] = undefined
|
// TODO: Remove from parent if grouped
|
||||||
// TODO: Remove shape from parent if grouped
|
})
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
nextPageState.selectedIds = [this.shape.id]
|
// Put back the initial shape
|
||||||
|
nextShapes[this.shape.id] = { ...nextShapes[this.shape.id], point: this.shape.point }
|
||||||
|
|
||||||
|
if (this.initialSiblings) {
|
||||||
|
nextShapes[this.shape.parentId] = {
|
||||||
|
children: [...this.initialSiblings, this.shape.id],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
document: {
|
document: {
|
||||||
pages: {
|
pages: {
|
||||||
[data.appState.currentPageId]: {
|
[data.appState.currentPageId]: {
|
||||||
shapes: nextShapes,
|
shapes: nextShapes,
|
||||||
bindings: nextBindings,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pageStates: {
|
pageStates: {
|
||||||
[data.appState.currentPageId]: nextPageState,
|
[data.appState.currentPageId]: {
|
||||||
|
selectedIds: [this.shape.id],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
complete(data: Data): TLDrawCommand {
|
complete(data: Data) {
|
||||||
const pageId = data.appState.currentPageId
|
const pageId = data.appState.currentPageId
|
||||||
|
|
||||||
const beforeShapes: Patch<Record<string, TLDrawShape>> = {}
|
const beforeShapes: Patch<Record<string, TLDrawShape>> = {}
|
||||||
|
@ -186,17 +198,28 @@ export class GridSession implements Session {
|
||||||
|
|
||||||
const afterSelectedIds: string[] = []
|
const afterSelectedIds: string[] = []
|
||||||
|
|
||||||
this.grid.forEach((row) =>
|
Object.values(this.grid).forEach((id) => {
|
||||||
row.forEach((id) => {
|
beforeShapes[id] = undefined
|
||||||
beforeShapes[id] = undefined
|
afterShapes[id] = TLDR.getShape(data, id, pageId)
|
||||||
afterShapes[id] = TLDR.getShape(data, id, pageId)
|
afterSelectedIds.push(id)
|
||||||
afterSelectedIds.push(id)
|
// TODO: Add shape to parent if grouped
|
||||||
// TODO: Add shape to parent if grouped
|
})
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
beforeShapes[this.shape.id] = this.shape
|
beforeShapes[this.shape.id] = this.shape
|
||||||
afterShapes[this.shape.id] = this.shape
|
|
||||||
|
// Add shapes to parent id
|
||||||
|
if (this.initialSiblings) {
|
||||||
|
beforeShapes[this.shape.parentId] = {
|
||||||
|
children: [...this.initialSiblings, this.shape.id],
|
||||||
|
}
|
||||||
|
|
||||||
|
afterShapes[this.shape.parentId] = {
|
||||||
|
children: [...this.initialSiblings, ...Object.values(this.grid)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no new shapes have been created, bail
|
||||||
|
if (afterSelectedIds.length === 1) return
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: 'grid',
|
id: 'grid',
|
||||||
|
|
|
@ -1660,40 +1660,10 @@ export class TLDrawState extends StateManager<Data> {
|
||||||
if (!session) return this
|
if (!session) return this
|
||||||
this.session = undefined
|
this.session = undefined
|
||||||
|
|
||||||
if (this.status === 'creating') {
|
|
||||||
return this.patchState(
|
|
||||||
{
|
|
||||||
document: {
|
|
||||||
pages: {
|
|
||||||
[this.currentPageId]: {
|
|
||||||
shapes: {
|
|
||||||
...Object.fromEntries(this.selectedIds.map((id) => [id, undefined])),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pageStates: {
|
|
||||||
[this.currentPageId]: {
|
|
||||||
selectedIds: [],
|
|
||||||
editingId: undefined,
|
|
||||||
bindingId: undefined,
|
|
||||||
hoveredId: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
`session:cancel_create:${session.constructor.name}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = session.cancel(this.state)
|
const result = session.cancel(this.state)
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
this.patchState(
|
this.patchState(result, `session:cancel:${session.constructor.name}`)
|
||||||
{
|
|
||||||
...session.cancel(this.state),
|
|
||||||
},
|
|
||||||
`session:cancel:${session.constructor.name}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this
|
return this
|
||||||
|
@ -2021,9 +1991,9 @@ export class TLDrawState extends StateManager<Data> {
|
||||||
* Duplicate one or more shapes.
|
* Duplicate one or more shapes.
|
||||||
* @param ids The ids to duplicate (defaults to selection).
|
* @param ids The ids to duplicate (defaults to selection).
|
||||||
*/
|
*/
|
||||||
duplicate = (ids = this.selectedIds): this => {
|
duplicate = (ids = this.selectedIds, point?: number[]): this => {
|
||||||
if (ids.length === 0) return this
|
if (ids.length === 0) return this
|
||||||
return this.setState(Commands.duplicate(this.state, ids))
|
return this.setState(Commands.duplicate(this.state, ids, point))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -52,10 +52,6 @@ export abstract class BaseTool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard events
|
|
||||||
onKeyDown?: TLKeyboardEventHandler
|
|
||||||
onKeyUp?: TLKeyboardEventHandler
|
|
||||||
|
|
||||||
// Camera Events
|
// Camera Events
|
||||||
onPan?: TLWheelEventHandler
|
onPan?: TLWheelEventHandler
|
||||||
onZoom?: TLWheelEventHandler
|
onZoom?: TLWheelEventHandler
|
||||||
|
@ -131,4 +127,32 @@ export abstract class BaseTool {
|
||||||
this.state.pinchZoom(info.point, info.delta, info.delta[2])
|
this.state.pinchZoom(info.point, info.delta, info.delta[2])
|
||||||
this.onPointerMove?.(info, e as unknown as React.PointerEvent)
|
this.onPointerMove?.(info, e as unknown as React.PointerEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------- Keys ---------------------- */
|
||||||
|
|
||||||
|
onKeyDown: TLKeyboardEventHandler = (key, info) => {
|
||||||
|
/* noop */
|
||||||
|
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
|
||||||
|
this.state.updateSession(
|
||||||
|
this.state.getPagePoint(info.point),
|
||||||
|
info.shiftKey,
|
||||||
|
info.altKey,
|
||||||
|
info.metaKey
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyUp: TLKeyboardEventHandler = (key, info) => {
|
||||||
|
/* noop */
|
||||||
|
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
|
||||||
|
this.state.updateSession(
|
||||||
|
this.state.getPagePoint(info.point),
|
||||||
|
info.shiftKey,
|
||||||
|
info.altKey,
|
||||||
|
info.metaKey
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ enum Status {
|
||||||
Pinching = 'pinching',
|
Pinching = 'pinching',
|
||||||
Brushing = 'brushing',
|
Brushing = 'brushing',
|
||||||
GridCloning = 'gridCloning',
|
GridCloning = 'gridCloning',
|
||||||
|
ClonePainting = 'clonePainting',
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SelectTool extends BaseTool {
|
export class SelectTool extends BaseTool {
|
||||||
|
@ -162,8 +163,7 @@ export class SelectTool extends BaseTool {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'Meta' || key === 'Control') {
|
if (key === 'Meta' || key === 'Control' || key === 'Alt') {
|
||||||
// TODO: Make all sessions have all of these arguments
|
|
||||||
this.state.updateSession(
|
this.state.updateSession(
|
||||||
this.state.getPagePoint(info.point),
|
this.state.getPagePoint(info.point),
|
||||||
info.shiftKey,
|
info.shiftKey,
|
||||||
|
@ -174,9 +174,7 @@ export class SelectTool extends BaseTool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyUp: TLKeyboardEventHandler = () => {
|
// Keyup is handled on BaseTool
|
||||||
/* noop */
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pointer Events (generic)
|
// Pointer Events (generic)
|
||||||
|
|
||||||
|
@ -263,6 +261,29 @@ export class SelectTool extends BaseTool {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { shapes, selectedIds, getShapeBounds } = this.state
|
||||||
|
|
||||||
|
if (info.shiftKey && info.altKey && selectedIds.length > 0) {
|
||||||
|
const point = this.state.getPagePoint(info.point)
|
||||||
|
const bounds = Utils.expandBounds(
|
||||||
|
Utils.getCommonBounds(selectedIds.map((id) => getShapeBounds(id))),
|
||||||
|
32
|
||||||
|
)
|
||||||
|
const centeredBounds = Utils.centerBounds(bounds, point)
|
||||||
|
|
||||||
|
if (!shapes.some((shape) => TLDR.getShapeUtils(shape).hitTestBounds(shape, centeredBounds))) {
|
||||||
|
this.state.duplicate(this.state.selectedIds, point)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.status === Status.Idle) {
|
||||||
|
this.setStatus(Status.ClonePainting)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
} else if (this.status === Status.ClonePainting) {
|
||||||
|
this.setStatus(Status.Idle)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.state.session) {
|
if (this.state.session) {
|
||||||
return this.state.updateSession(
|
return this.state.updateSession(
|
||||||
this.state.getPagePoint(info.point),
|
this.state.getPagePoint(info.point),
|
||||||
|
@ -335,10 +356,16 @@ export class SelectTool extends BaseTool {
|
||||||
onPointCanvas: TLCanvasEventHandler = (info) => {
|
onPointCanvas: TLCanvasEventHandler = (info) => {
|
||||||
// Unless the user is holding shift or meta, clear the current selection
|
// Unless the user is holding shift or meta, clear the current selection
|
||||||
if (!info.shiftKey) {
|
if (!info.shiftKey) {
|
||||||
this.deselectAll()
|
|
||||||
if (this.state.pageState.editingId) {
|
if (this.state.pageState.editingId) {
|
||||||
this.state.setEditingId()
|
this.state.setEditingId()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (info.altKey && this.state.selectedIds.length > 0) {
|
||||||
|
this.state.duplicate(this.state.selectedIds, this.state.getPagePoint(info.point))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deselectAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setStatus(Status.PointingCanvas)
|
this.setStatus(Status.PointingCanvas)
|
||||||
|
|
|
@ -68,6 +68,10 @@ export class TextTool extends BaseTool {
|
||||||
|
|
||||||
/* ----------------- Event Handlers ----------------- */
|
/* ----------------- Event Handlers ----------------- */
|
||||||
|
|
||||||
|
onKeyUp = () => void null
|
||||||
|
|
||||||
|
onKeyDown = () => void null
|
||||||
|
|
||||||
onPointerDown: TLPointerEventHandler = (info) => {
|
onPointerDown: TLPointerEventHandler = (info) => {
|
||||||
if (this.status === Status.Idle) {
|
if (this.status === Status.Idle) {
|
||||||
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
|
const pagePoint = Vec.round(this.state.getPagePoint(info.point))
|
||||||
|
|
Loading…
Reference in a new issue