[fix] copy bindings (#124)

* v0.0.103

* Copies bindings together with shapes that are bound

* Remove old shape bindings from copied shape handles
This commit is contained in:
Steve Ruiz 2021-09-24 13:47:11 +01:00 committed by GitHub
parent 84a283828d
commit ea66362135
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 184 additions and 53 deletions

View file

@ -1,5 +1,5 @@
{ {
"version": "0.0.102", "version": "0.0.103",
"registry": "https://registry.npmjs.org/", "registry": "https://registry.npmjs.org/",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tldraw/core", "name": "@tldraw/core",
"version": "0.0.102", "version": "0.0.103",
"private": false, "private": false,
"description": "A tiny little drawing app (core)", "description": "A tiny little drawing app (core)",
"author": "@steveruizok", "author": "@steveruizok",
@ -57,8 +57,8 @@
"react-dom": "^16.8 || ^17.0" "react-dom": "^16.8 || ^17.0"
}, },
"dependencies": { "dependencies": {
"@tldraw/intersect": "^0.0.102", "@tldraw/intersect": "^0.0.103",
"@tldraw/vec": "^0.0.102", "@tldraw/vec": "^0.0.103",
"@use-gesture/react": "^10.0.0-beta.26" "@use-gesture/react": "^10.0.0-beta.26"
}, },
"gitHead": "5cb031ddc264846ec6732d7179511cddea8ef034" "gitHead": "5cb031ddc264846ec6732d7179511cddea8ef034"

View file

@ -1,6 +1,6 @@
{ {
"name": "@tldraw/dev", "name": "@tldraw/dev",
"version": "0.0.102", "version": "0.0.103",
"private": true, "private": true,
"description": "A tiny little drawing app (dev)", "description": "A tiny little drawing app (dev)",
"author": "@steveruizok", "author": "@steveruizok",
@ -19,7 +19,7 @@
], ],
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@tldraw/tldraw": "^0.0.102", "@tldraw/tldraw": "^0.0.103",
"idb": "^6.1.2", "idb": "^6.1.2",
"react": ">=16.8", "react": ">=16.8",
"react-dom": "^16.8 || ^17.0", "react-dom": "^16.8 || ^17.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tldraw/intersect", "name": "@tldraw/intersect",
"version": "0.0.102", "version": "0.0.103",
"private": false, "private": false,
"description": "A tiny little drawing app (intersect)", "description": "A tiny little drawing app (intersect)",
"author": "@steveruizok", "author": "@steveruizok",
@ -48,7 +48,7 @@
"typescript": "^4.4.2" "typescript": "^4.4.2"
}, },
"dependencies": { "dependencies": {
"@tldraw/vec": "^0.0.102" "@tldraw/vec": "^0.0.103"
}, },
"gitHead": "5cb031ddc264846ec6732d7179511cddea8ef034" "gitHead": "5cb031ddc264846ec6732d7179511cddea8ef034"
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@tldraw/tldraw", "name": "@tldraw/tldraw",
"version": "0.0.102", "version": "0.0.103",
"private": false, "private": false,
"description": "A tiny little drawing app (editor)", "description": "A tiny little drawing app (editor)",
"author": "@steveruizok", "author": "@steveruizok",
@ -64,9 +64,9 @@
"@radix-ui/react-tooltip": "^0.0.20", "@radix-ui/react-tooltip": "^0.0.20",
"@stitches/core": "^1.2.0", "@stitches/core": "^1.2.0",
"@stitches/react": "^1.0.0", "@stitches/react": "^1.0.0",
"@tldraw/core": "^0.0.102", "@tldraw/core": "^0.0.103",
"@tldraw/intersect": "^0.0.102", "@tldraw/intersect": "^0.0.103",
"@tldraw/vec": "^0.0.102", "@tldraw/vec": "^0.0.103",
"perfect-freehand": "^1.0.12", "perfect-freehand": "^1.0.12",
"react-hotkeys-hook": "^3.4.0", "react-hotkeys-hook": "^3.4.0",
"rko": "^0.5.25" "rko": "^0.5.25"

View file

@ -16,6 +16,7 @@ Array [
}, },
"pages": Object { "pages": Object {
"page1": Object { "page1": Object {
"bindings": Object {},
"shapes": Object { "shapes": Object {
"rect1": Object { "rect1": Object {
"childIndex": 1, "childIndex": 1,
@ -53,6 +54,7 @@ Array [
}, },
"pages": Object { "pages": Object {
"page1": Object { "page1": Object {
"bindings": Object {},
"shapes": Object { "shapes": Object {
"rect1": undefined, "rect1": undefined,
}, },
@ -74,6 +76,7 @@ Array [
}, },
"pages": Object { "pages": Object {
"page1": Object { "page1": Object {
"bindings": Object {},
"shapes": Object { "shapes": Object {
"rect2": Object { "rect2": Object {
"childIndex": 1, "childIndex": 1,
@ -113,6 +116,7 @@ Array [
}, },
"pages": Object { "pages": Object {
"page1": Object { "page1": Object {
"bindings": Object {},
"shapes": Object { "shapes": Object {
"rect2": undefined, "rect2": undefined,
}, },

View file

@ -21,7 +21,7 @@ describe('Create command', () => {
it('does, undoes and redoes command', () => { it('does, undoes and redoes command', () => {
const shape = { ...tlstate.getShape('rect1'), id: 'rect4' } const shape = { ...tlstate.getShape('rect1'), id: 'rect4' }
tlstate.create(shape) tlstate.create([shape])
expect(tlstate.getShape('rect4')).toBeTruthy() expect(tlstate.getShape('rect4')).toBeTruthy()
@ -33,4 +33,6 @@ describe('Create command', () => {
expect(tlstate.getShape('rect4')).toBeTruthy() expect(tlstate.getShape('rect4')).toBeTruthy()
}) })
it.todo('Creates bindings')
}) })

View file

@ -1,8 +1,12 @@
import type { Patch } from 'rko' import type { Patch } from 'rko'
import { TLDR } from '~state/tldr' import { TLDR } from '~state/tldr'
import type { TLDrawShape, Data, TLDrawCommand } from '~types' import type { TLDrawShape, Data, TLDrawCommand, TLDrawBinding } from '~types'
export function create(data: Data, shapes: TLDrawShape[]): TLDrawCommand { export function create(
data: Data,
shapes: TLDrawShape[],
bindings: TLDrawBinding[] = []
): TLDrawCommand {
const { currentPageId } = data.appState const { currentPageId } = data.appState
const beforeShapes: Record<string, Patch<TLDrawShape> | undefined> = {} const beforeShapes: Record<string, Patch<TLDrawShape> | undefined> = {}
@ -13,6 +17,14 @@ export function create(data: Data, shapes: TLDrawShape[]): TLDrawCommand {
afterShapes[shape.id] = shape afterShapes[shape.id] = shape
}) })
const beforeBindings: Record<string, Patch<TLDrawBinding> | undefined> = {}
const afterBindings: Record<string, Patch<TLDrawBinding> | undefined> = {}
bindings.forEach((binding) => {
beforeBindings[binding.id] = undefined
afterBindings[binding.id] = binding
})
return { return {
id: 'create', id: 'create',
before: { before: {
@ -20,6 +32,7 @@ export function create(data: Data, shapes: TLDrawShape[]): TLDrawCommand {
pages: { pages: {
[currentPageId]: { [currentPageId]: {
shapes: beforeShapes, shapes: beforeShapes,
bindings: beforeBindings,
}, },
}, },
pageStates: { pageStates: {
@ -34,6 +47,7 @@ export function create(data: Data, shapes: TLDrawShape[]): TLDrawCommand {
pages: { pages: {
[currentPageId]: { [currentPageId]: {
shapes: afterShapes, shapes: afterShapes,
bindings: afterBindings,
}, },
}, },
pageStates: { pageStates: {

View file

@ -11,7 +11,7 @@ describe('Draw session', () => {
expect(tlstate.getShape('draw1')).toBe(undefined) expect(tlstate.getShape('draw1')).toBe(undefined)
tlstate tlstate
.create({ .createShapes({
id: 'draw1', id: 'draw1',
parentId: 'page1', parentId: 'page1',
name: 'Draw', name: 'Draw',

View file

@ -1,13 +1,13 @@
import { TLDrawState } from './tlstate' import { TLDrawState } from './tlstate'
import { mockDocument, TLStateUtils } from '~test' import { mockDocument, TLStateUtils } from '~test'
import { ColorStyle, TLDrawShapeType } from '~types' import { ArrowShape, ColorStyle, TLDrawShapeType } from '~types'
describe('TLDrawState', () => { describe('TLDrawState', () => {
const tlstate = new TLDrawState() const tlstate = new TLDrawState()
const tlu = new TLStateUtils(tlstate) const tlu = new TLStateUtils(tlstate)
describe('Copy and Paste', () => { describe('When copying and pasting...', () => {
it('copies a shape', () => { it('copies a shape', () => {
tlstate.loadDocument(mockDocument).deselectAll().copy(['rect1']) tlstate.loadDocument(mockDocument).deselectAll().copy(['rect1'])
}) })
@ -45,6 +45,62 @@ describe('TLDrawState', () => {
expect(Object.keys(tlstate.page.shapes).length).toBe(1) expect(Object.keys(tlstate.page.shapes).length).toBe(1)
}) })
it.todo("Pastes in to the top child index of the page's children.")
it.todo('Pastes in the correct child index order.')
})
describe('When copying and pasting a shape with bindings', () => {
it('copies two bound shapes and their binding', () => {
const tlstate = new TLDrawState()
tlstate
.createShapes(
{ type: TLDrawShapeType.Rectangle, id: 'target1', point: [0, 0], size: [100, 100] },
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([55, 55])
.completeSession()
expect(tlstate.bindings.length).toBe(1)
tlstate.selectAll().copy().paste()
const newArrow = tlstate.shapes.sort((a, b) => b.childIndex - a.childIndex)[0] as ArrowShape
expect(newArrow.handles.start.bindingId).not.toBe(
tlstate.getShape<ArrowShape>('arrow1').handles.start.bindingId
)
expect(tlstate.bindings.length).toBe(2)
})
it('removes bindings from copied shape handles', () => {
const tlstate = new TLDrawState()
tlstate
.createShapes(
{ type: TLDrawShapeType.Rectangle, id: 'target1', point: [0, 0], size: [100, 100] },
{ type: TLDrawShapeType.Arrow, id: 'arrow1', point: [200, 200] }
)
.select('arrow1')
.startHandleSession([200, 200], 'start')
.updateHandleSession([55, 55])
.completeSession()
expect(tlstate.bindings.length).toBe(1)
expect(tlstate.getShape<ArrowShape>('arrow1').handles.start.bindingId).toBeDefined()
tlstate.select('arrow1').copy().paste()
const newArrow = tlstate.shapes.sort((a, b) => b.childIndex - a.childIndex)[0] as ArrowShape
expect(newArrow.handles.start.bindingId).toBeUndefined()
})
}) })
describe('Selection', () => { describe('Selection', () => {

View file

@ -107,7 +107,10 @@ export class TLDrawState extends StateManager<Data> {
pointer: 0, pointer: 0,
} }
clipboard?: TLDrawShape[] clipboard?: {
shapes: TLDrawShape[]
bindings: TLDrawBinding[]
}
session?: Session session?: Session
@ -845,16 +848,32 @@ export class TLDrawState extends StateManager<Data> {
* @param ids The ids of the shapes to copy. * @param ids The ids of the shapes to copy.
*/ */
copy = (ids = this.selectedIds): this => { copy = (ids = this.selectedIds): this => {
const clones = ids const copyingShapeIds = ids.flatMap((id) =>
.flatMap((id) => TLDR.getDocumentBranch(this.state, id, this.currentPageId)) TLDR.getDocumentBranch(this.state, id, this.currentPageId)
.map((id) => this.getShape(id, this.currentPageId)) )
if (clones.length === 0) return this const copyingShapes = copyingShapeIds.map((id) =>
Utils.deepClone(this.getShape(id, this.currentPageId))
)
this.clipboard = clones if (copyingShapes.length === 0) return this
const copyingBindings: TLDrawBinding[] = Object.values(this.page.bindings).filter(
(binding) =>
copyingShapeIds.includes(binding.fromId) && copyingShapeIds.includes(binding.toId)
)
this.clipboard = {
shapes: copyingShapes,
bindings: copyingBindings,
}
try { try {
const text = JSON.stringify({ type: 'tldr/clipboard', shapes: clones }) const text = JSON.stringify({
type: 'tldr/clipboard',
shapes: copyingShapes,
bindings: copyingBindings,
})
navigator.clipboard.writeText(text).then( navigator.clipboard.writeText(text).then(
() => { () => {
@ -879,15 +898,47 @@ export class TLDrawState extends StateManager<Data> {
* @param point * @param point
*/ */
paste = (point?: number[]) => { paste = (point?: number[]) => {
const pasteInCurrentPage = (shapes: TLDrawShape[]) => { const pasteInCurrentPage = (shapes: TLDrawShape[], bindings: TLDrawBinding[]) => {
const idsMap = Object.fromEntries( const idsMap: Record<string, string> = {}
shapes.map((shape: TLDrawShape) => [shape.id, Utils.uniqueId()])
)
const shapesToPaste = shapes.map((shape: TLDrawShape) => ({ shapes.forEach((shape) => (idsMap[shape.id] = Utils.uniqueId()))
bindings.forEach((binding) => (idsMap[binding.id] = Utils.uniqueId()))
let startIndex = TLDR.getTopChildIndex(this.state, this.currentPageId)
const shapesToPaste = shapes
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => {
const parentShapeId = idsMap[shape.parentId]
const copy = {
...shape, ...shape,
id: idsMap[shape.id], id: idsMap[shape.id],
parentId: idsMap[shape.parentId] || this.currentPageId, parentId: parentShapeId || this.currentPageId,
}
if (!parentShapeId) {
copy.childIndex = startIndex
startIndex++
}
if (copy.handles) {
Object.values(copy.handles).forEach((handle) => {
if (handle.bindingId) {
handle.bindingId = idsMap[handle.bindingId]
}
})
}
return copy
})
const bindingsToPaste = bindings.map((binding) => ({
...binding,
id: idsMap[binding.id],
toId: idsMap[binding.toId],
fromId: idsMap[binding.fromId],
})) }))
const commonBounds = Utils.getCommonBounds(shapesToPaste.map(TLDR.getBounds)) const commonBounds = Utils.getCommonBounds(shapesToPaste.map(TLDR.getBounds))
@ -915,11 +966,15 @@ export class TLDrawState extends StateManager<Data> {
Utils.getBoundsCenter(commonBounds) Utils.getBoundsCenter(commonBounds)
) )
this.createShapes( this.create(
...shapesToPaste.map((shape) => ({ shapesToPaste.map((shape) =>
TLDR.getShapeUtils(shape.type).create({
...shape, ...shape,
point: Vec.round(Vec.add(shape.point, delta)), point: Vec.round(Vec.add(shape.point, delta)),
})) parentId: shape.parentId || this.currentPageId,
})
),
bindingsToPaste
) )
} }
try { try {
@ -929,15 +984,16 @@ export class TLDrawState extends StateManager<Data> {
navigator.clipboard.readText().then((result) => { navigator.clipboard.readText().then((result) => {
try { try {
const data: { type: string; shapes: TLDrawShape[] } = JSON.parse(result) const data: { type: string; shapes: TLDrawShape[]; bindings: TLDrawBinding[] } =
JSON.parse(result)
if (data.type !== 'tldr/clipboard') { if (data.type !== 'tldr/clipboard') {
throw Error('The pasted string was not from the tldraw clipboard.') throw Error('The pasted string was not from the tldraw clipboard.')
} }
pasteInCurrentPage(data.shapes) pasteInCurrentPage(data.shapes, data.bindings)
} catch (e) { } catch (e) {
console.warn(e) console.log(e)
const shapeId = Utils.uniqueId() const shapeId = Utils.uniqueId()
@ -953,12 +1009,11 @@ export class TLDrawState extends StateManager<Data> {
this.select(shapeId) this.select(shapeId)
} }
}) })
} catch (e: any) { } catch (e) {
console.warn(e.message)
// Navigator does not support clipboard. Note that this fallback will // Navigator does not support clipboard. Note that this fallback will
// not support pasting from one document to another. // not support pasting from one document to another.
if (this.clipboard) { if (this.clipboard) {
pasteInCurrentPage(this.clipboard) pasteInCurrentPage(this.clipboard.shapes, this.clipboard.bindings)
} }
} }
@ -1761,12 +1816,12 @@ export class TLDrawState extends StateManager<Data> {
): this => { ): this => {
if (shapes.length === 0) return this if (shapes.length === 0) return this
return this.create( return this.create(
...shapes.map((shape) => { shapes.map((shape) =>
return TLDR.getShapeUtils(shape.type).create({ TLDR.getShapeUtils(shape.type).create({
...shape, ...shape,
parentId: shape.parentId || this.currentPageId, parentId: shape.parentId || this.currentPageId,
}) })
}) )
) )
} }
@ -1805,9 +1860,9 @@ export class TLDrawState extends StateManager<Data> {
* @param shapes An array of shapes. * @param shapes An array of shapes.
* @command * @command
*/ */
create = (...shapes: TLDrawShape[]): this => { create = (shapes: TLDrawShape[] = [], bindings: TLDrawBinding[] = []): this => {
if (shapes.length === 0) return this if (shapes.length === 0) return this
return this.setState(Commands.create(this.state, shapes)) return this.setState(Commands.create(this.state, shapes, bindings))
} }
/** /**

View file

@ -1,6 +1,6 @@
{ {
"name": "@tldraw/vec", "name": "@tldraw/vec",
"version": "0.0.102", "version": "0.0.103",
"private": false, "private": false,
"description": "A tiny little drawing app (vec)", "description": "A tiny little drawing app (vec)",
"author": "@steveruizok", "author": "@steveruizok",

View file

@ -1,6 +1,6 @@
{ {
"name": "www", "name": "www",
"version": "0.0.102", "version": "0.0.103",
"private": true, "private": true,
"description": "A tiny little drawing app (site).", "description": "A tiny little drawing app (site).",
"repository": { "repository": {
@ -21,7 +21,7 @@
"@sentry/react": "^6.13.2", "@sentry/react": "^6.13.2",
"@sentry/tracing": "^6.13.2", "@sentry/tracing": "^6.13.2",
"@stitches/react": "^1.0.0", "@stitches/react": "^1.0.0",
"@tldraw/tldraw": "^0.0.102", "@tldraw/tldraw": "^0.0.103",
"next": "11.1.2", "next": "11.1.2",
"next-auth": "3.29.0", "next-auth": "3.29.0",
"next-pwa": "^5.2.23", "next-pwa": "^5.2.23",