Add overwrite option to insertContent (#730)
This commit is contained in:
parent
65ff5075f0
commit
0a52b5c317
3 changed files with 234 additions and 191 deletions
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -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],
|
||||||
|
|
Loading…
Reference in a new issue