[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:
Steve Ruiz 2021-10-15 17:14:36 +01:00 committed by GitHub
parent 0d8d45d873
commit 32b2ae88ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 271 additions and 147 deletions

View file

@ -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()

View file

@ -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" />
</> </>
) )
} }

View file

@ -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,

View file

@ -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])
})
})

View file

@ -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: {

View file

@ -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: {

View file

@ -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',

View file

@ -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))
} }
/** /**

View file

@ -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
}
}
} }

View file

@ -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)

View file

@ -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))