Add overwrite option to insertContent (#730)

This commit is contained in:
Steve Ruiz 2022-06-20 20:36:23 +01:00 committed by GitHub
parent 65ff5075f0
commit 0a52b5c317
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 234 additions and 191 deletions

View file

@ -2169,7 +2169,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
// but if only one shape is included, discard the binding // but if only one shape is included, discard the binding
const bindings = Object.values(page.bindings) const bindings = Object.values(page.bindings)
.filter((binding) => { .filter((binding) => {
if (idsSet.has(binding.fromId) && idsSet.has(binding.toId)) { if (idsSet.has(binding.fromId) || idsSet.has(binding.toId)) {
return true return true
} }
@ -2261,11 +2261,12 @@ export class TldrawApp extends StateManager<TDSnapshot> {
* @param content.assets (optional) An array of TDAsset objects. * @param content.assets (optional) An array of TDAsset objects.
* @param opts (optional) An options object * @param opts (optional) An options object
* @param opts.point (optional) A point at which to paste the content. * @param opts.point (optional) A point at which to paste the content.
* @param opts.select (optional) When true, the inserted shapes will be selected. * @param opts.select (optional) When true, the inserted shapes will be selected. Defaults to false.
* @param opts.overwrite (optional) When true, the inserted shapes and bindings will overwrite any existing shapes and bindings. Defaults to false.
*/ */
insertContent = ( insertContent = (
content: { shapes: TDShape[]; bindings?: TDBinding[]; assets?: TDAsset[] }, content: { shapes: TDShape[]; bindings?: TDBinding[]; assets?: TDAsset[] },
opts = {} as { point?: number[]; select?: boolean } opts = {} as { point?: number[]; select?: boolean; overwrite?: boolean }
) => { ) => {
return this.setState(Commands.insertContent(this, content, opts), 'insert_content') return this.setState(Commands.insertContent(this, content, opts), 'insert_content')
} }

View file

@ -115,8 +115,8 @@ describe('insert command', () => {
const content = app.getContent()! const content = app.getContent()!
// getContent does not include the incomplete binding // getContent DOES include the incomplete binding
expect(Object.values(content.bindings).length).toBe(0) expect(Object.values(content.bindings).length).toBe(1)
app.insertContent(content) app.insertContent(content)
@ -185,3 +185,21 @@ describe('insert command', () => {
expect(app.shapes.length).toBe(size) expect(app.shapes.length).toBe(size)
}) })
}) })
describe('When opts.overwrite is true', () => {
it('replaces content', () => {
const content = app.getContent()!
const size = app.shapes.length
const ids = app.shapes.map((s) => s.id)
app.insertContent(content, { overwrite: true })
expect(app.shapes.length).toBe(size)
expect(app.shapes.map((s) => s.id)).toMatchObject(ids)
})
it('restores content under the same ids', () => {
const content = app.getContent()!
const ids = app.shapes.map((s) => s.id)
app.deleteAll().insertContent(content, { overwrite: true })
expect(app.shapes.map((s) => s.id)).toMatchObject(ids)
})
})

View file

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Utils } from '@tldraw/core' import { Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec' import { Vec } from '@tldraw/vec'
import { GRID_SIZE } from '~constants'
import { TLDR } from '~state/TLDR' import { TLDR } from '~state/TLDR'
import type { PagePartial, TldrawCommand, TDShape, TDBinding, TDAsset } from '~types' import type { PagePartial, TldrawCommand, TDShape, TDBinding, TDAsset } from '~types'
import type { TldrawApp } from '../../internal' import type { TldrawApp } from '../../internal'
@ -9,209 +8,240 @@ import type { TldrawApp } from '../../internal'
export function insertContent( export function insertContent(
app: TldrawApp, app: TldrawApp,
content: { shapes: TDShape[]; bindings?: TDBinding[]; assets?: TDAsset[] }, content: { shapes: TDShape[]; bindings?: TDBinding[]; assets?: TDAsset[] },
opts = {} as { point?: number[]; select?: boolean } opts = {} as { point?: number[]; select?: boolean; overwrite?: boolean }
): TldrawCommand { ): TldrawCommand {
const { currentPageId } = app const { currentPageId } = app
const { point, select, overwrite } = opts
const page = app.document.pages[currentPageId]
const before: PagePartial = { const before: PagePartial = {
shapes: {}, shapes: {},
bindings: {}, bindings: {},
} }
const afterAssets: Record<string, TDAsset> = {}
const after: PagePartial = { const after: PagePartial = {
shapes: {}, shapes: {},
bindings: {}, bindings: {},
} }
const oldToNewIds: Record<string, string> = {} if (overwrite) {
// Map shapes and bindings onto new IDs to avoid overwriting existing content.
// The index of the new shape for (const shape of content.shapes) {
let nextIndex = TLDR.getTopChildIndex(app.state, currentPageId) before.shapes[shape.id] = page.shapes[shape.id]
after.shapes[shape.id] = shape
const shapesToInsert: TDShape[] = content.shapes
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => {
const newShapeId = Utils.uniqueId()
oldToNewIds[shape.id] = newShapeId
// The redo should include a clone of the new shape
return {
...Utils.deepClone(shape),
id: newShapeId,
}
})
const visited = new Set<string>()
// Iterate through the list, starting from the front
while (shapesToInsert.length > 0) {
const shape = shapesToInsert.shift()
if (!shape) break
visited.add(shape.id)
if (shape.parentId === 'currentPageId') {
shape.parentId = currentPageId
shape.childIndex = nextIndex++
} else {
// The shape had another shape as its parent.
// Re-assign the shape's parentId to the new id
shape.parentId = oldToNewIds[shape.parentId]
// Has that parent been added yet to the after object?
const parent = after.shapes[shape.parentId]
if (!parent) {
if (visited.has(shape.id)) {
// If we've already visited this shape, then that means
// its parent was not among the shapes to insert. Set it
// to be a child of the current page instead.
shape.parentId = 'currentPageId'
}
// If the parent hasn't been added yet, push this shape
// to back of the queue; we'll try and add it again later
shapesToInsert.push(shape)
continue
}
// If we've found the parent, add this shape's id to its children
parent.children!.push(shape.id)
} }
if (content.bindings) {
// If the inserting shape has its own children, set the children to for (const binding of content.bindings) {
// an empty array; we'll add them later, as just shown above before.bindings[binding.id] = page.bindings[binding.id]
if (shape.children) { after.bindings[binding.id] = binding
shape.children = [] }
} }
if (content.assets) {
// The undo should remove the inserted shape for (const asset of content.assets) {
before.shapes[shape.id] = undefined afterAssets[asset.id] = asset
}
// The redo should include the inserted shape
after.shapes[shape.id] = shape
}
Object.values(after.shapes).forEach((shape) => {
// If the shape used to have children, but no longer does have children,
// then delete the shape. This prevents inserting groups without children.
if (shape!.children && shape!.children.length === 0) {
delete before.shapes[shape!.id!]
delete after.shapes[shape!.id!]
} }
}) } else {
// Map shapes and bindings onto new IDs to avoid overwriting existing content.
// Insert bindings const oldToNewIds: Record<string, string> = {}
if (content.bindings) {
content.bindings.forEach((binding) => {
const newBindingId = Utils.uniqueId()
oldToNewIds[binding.id] = newBindingId
const toId = oldToNewIds[binding.toId] // The index of the new shape
const fromId = oldToNewIds[binding.fromId] let nextIndex = TLDR.getTopChildIndex(app.state, currentPageId)
// If the binding is "to" or "from" a shape that hasn't been inserted, const shapesToInsert: TDShape[] = content.shapes
// we'll need to skip the binding and remove it from any shape that .sort((a, b) => a.childIndex - b.childIndex)
// references it. .map((shape) => {
if (!toId || !fromId) { const newShapeId = Utils.uniqueId()
if (fromId) { oldToNewIds[shape.id] = newShapeId
const handles = after.shapes[fromId]!.handles
if (handles) { // The redo should include a clone of the new shape
Object.values(handles).forEach((handle) => { return {
if (handle!.bindingId === binding.id) { ...Utils.deepClone(shape),
handle!.bindingId = undefined id: newShapeId,
}
})
}
} }
if (toId) {
const handles = after.shapes[toId]!.handles
if (handles) {
Object.values(handles).forEach((handle) => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = undefined
}
})
}
}
return
}
// Update the shape's to and from references to the new bindingid
const fromHandles = after.shapes[fromId]!.handles
if (fromHandles) {
Object.values(fromHandles).forEach((handle) => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = newBindingId
}
})
}
const toHandles = after.shapes[toId]!.handles
if (toHandles) {
Object.values(after.shapes[toId]!.handles!).forEach((handle) => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = newBindingId
}
})
}
const newBinding = {
...Utils.deepClone(binding),
id: newBindingId,
toId,
fromId,
}
// The undo should remove the inserted binding
before.bindings[newBinding.id] = undefined
// The redo should include the inserted binding
after.bindings[newBinding.id] = newBinding
})
}
// Now move the shapes
const shapesToMove = Object.values(after.shapes) as TDShape[]
const { point, select } = opts
if (shapesToMove.length > 0) {
if (point) {
// Move the shapes so that they're centered on the given point
const commonBounds = Utils.getCommonBounds(shapesToMove.map((shape) => TLDR.getBounds(shape)))
const center = Utils.getBoundsCenter(commonBounds)
shapesToMove.forEach((shape) => {
if (!shape.point) return
shape.point = Vec.sub(point, Vec.sub(center, shape.point))
}) })
} else {
const commonBounds = Utils.getCommonBounds(shapesToMove.map(TLDR.getBounds))
if ( const visited = new Set<string>()
!(
Utils.boundsContain(app.viewport, commonBounds) || // Iterate through the list, starting from the front
Utils.boundsCollide(app.viewport, commonBounds) while (shapesToInsert.length > 0) {
const shape = shapesToInsert.shift()
if (!shape) break
visited.add(shape.id)
if (shape.parentId === 'currentPageId') {
shape.parentId = currentPageId
shape.childIndex = nextIndex++
} else {
// The shape had another shape as its parent.
// Re-assign the shape's parentId to the new id
shape.parentId = oldToNewIds[shape.parentId]
// Has that parent been added yet to the after object?
const parent = after.shapes[shape.parentId]
if (!parent) {
if (visited.has(shape.id)) {
// If we've already visited this shape, then that means
// its parent was not among the shapes to insert. Set it
// to be a child of the current page instead.
shape.parentId = 'currentPageId'
}
// If the parent hasn't been added yet, push this shape
// to back of the queue; we'll try and add it again later
shapesToInsert.push(shape)
continue
}
// If we've found the parent, add this shape's id to its children
parent.children!.push(shape.id)
}
// If the inserting shape has its own children, set the children to
// an empty array; we'll add them later, as just shown above
if (shape.children) {
shape.children = []
}
// The undo should remove the inserted shape
before.shapes[shape.id] = undefined
// The redo should include the inserted shape
after.shapes[shape.id] = shape
}
Object.values(after.shapes).forEach((shape) => {
// If the shape used to have children, but no longer does have children,
// then delete the shape. This prevents inserting groups without children.
if (shape!.children && shape!.children.length === 0) {
delete before.shapes[shape!.id!]
delete after.shapes[shape!.id!]
}
})
// Insert bindings
if (content.bindings) {
content.bindings.forEach((binding) => {
const newBindingId = Utils.uniqueId()
oldToNewIds[binding.id] = newBindingId
const toId = oldToNewIds[binding.toId]
const fromId = oldToNewIds[binding.fromId]
// If the binding is "to" or "from" a shape that hasn't been inserted,
// we'll need to skip the binding and remove it from any shape that
// references it.
if (!toId || !fromId) {
if (fromId) {
const handles = after.shapes[fromId]!.handles
if (handles) {
Object.values(handles).forEach((handle) => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = undefined
}
})
}
}
if (toId) {
const handles = after.shapes[toId]!.handles
if (handles) {
Object.values(handles).forEach((handle) => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = undefined
}
})
}
}
return
}
// Update the shape's to and from references to the new bindingid
const fromHandles = after.shapes[fromId]!.handles
if (fromHandles) {
Object.values(fromHandles).forEach((handle) => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = newBindingId
}
})
}
const toHandles = after.shapes[toId]!.handles
if (toHandles) {
Object.values(after.shapes[toId]!.handles!).forEach((handle) => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = newBindingId
}
})
}
const newBinding = {
...Utils.deepClone(binding),
id: newBindingId,
toId,
fromId,
}
// The undo should remove the inserted binding
before.bindings[newBinding.id] = undefined
// The redo should include the inserted binding
after.bindings[newBinding.id] = newBinding
})
}
// Now move the shapes
const shapesToMove = Object.values(after.shapes) as TDShape[]
if (shapesToMove.length > 0) {
if (point) {
// Move the shapes so that they're centered on the given point
const commonBounds = Utils.getCommonBounds(
shapesToMove.map((shape) => TLDR.getBounds(shape))
) )
) { const center = Utils.getBoundsCenter(commonBounds)
const center = Vec.toFixed(app.getPagePoint(app.centerPoint))
const centeredBounds = Utils.centerBounds(commonBounds, center)
const delta = Vec.sub(
Utils.getBoundsCenter(centeredBounds),
Utils.getBoundsCenter(commonBounds)
)
shapesToMove.forEach((shape) => { shapesToMove.forEach((shape) => {
shape.point = Vec.toFixed(Vec.add(shape.point, delta)) if (!shape.point) return
shape.point = Vec.sub(point, Vec.sub(center, shape.point))
}) })
} else {
const commonBounds = Utils.getCommonBounds(shapesToMove.map(TLDR.getBounds))
if (
!(
Utils.boundsContain(app.viewport, commonBounds) ||
Utils.boundsCollide(app.viewport, commonBounds)
)
) {
const center = Vec.toFixed(app.getPagePoint(app.centerPoint))
const centeredBounds = Utils.centerBounds(commonBounds, center)
const delta = Vec.sub(
Utils.getBoundsCenter(centeredBounds),
Utils.getBoundsCenter(commonBounds)
)
shapesToMove.forEach((shape) => {
shape.point = Vec.toFixed(Vec.add(shape.point, delta))
})
}
}
}
if (content.assets) {
for (const asset of content.assets) {
afterAssets[asset.id] = asset
} }
} }
} }
@ -233,13 +263,7 @@ export function insertContent(
pages: { pages: {
[currentPageId]: after, [currentPageId]: after,
}, },
assets: content.assets assets: afterAssets,
? Object.fromEntries(
content.assets
.filter((asset) => !app.document.assets[asset.id])
.map((asset) => [asset.id, asset])
)
: {},
pageStates: { pageStates: {
[currentPageId]: { [currentPageId]: {
selectedIds: select ? Object.keys(after.shapes) : [...app.selectedIds], selectedIds: select ? Object.keys(after.shapes) : [...app.selectedIds],