[improvement] Add getContent and insertContent methods (#726)

* Add insertContent method, update copyJson

* Add more tests

* Update TldrawApp.spec.ts

* Adds option object for select, point, and uses in paste
This commit is contained in:
Steve Ruiz 2022-06-19 14:47:43 +01:00 committed by GitHub
parent 0cfa44f4d2
commit 6d91916804
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1384 additions and 224 deletions

View file

@ -4,6 +4,9 @@ import { ArrowShape, ColorStyle, SessionType, TDShapeType } from '~types'
import { deepCopy } from './StateManager/copy'
import type { SelectTool } from './tools/SelectTool'
window.focus = jest.fn()
global.console.warn = jest.fn()
describe('TldrawTestApp', () => {
describe('When copying and pasting...', () => {
it('copies a shape', () => {

View file

@ -81,6 +81,7 @@ import { StickyTool } from './tools/StickyTool'
import { StateManager } from './StateManager'
import { clearPrevSize } from './shapes/shared/getTextSize'
import { getClipboard, setClipboard } from './IdbClipboard'
import { deepCopy } from './StateManager/copy'
const uuid = Utils.uniqueId()
@ -1713,43 +1714,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
/* Clipboard */
/* -------------------------------------------------- */
private getClipboard(ids = this.selectedIds):
| {
shapes: TDShape[]
bindings: TDBinding[]
assets: TDAsset[]
}
| undefined {
const copyingShapeIds = ids.flatMap((id) =>
TLDR.getDocumentBranch(this.state, id, this.currentPageId)
)
const copyingShapes = copyingShapeIds.map((id) =>
Utils.deepClone(this.getShape(id, this.currentPageId))
)
if (copyingShapes.length === 0) return
const copyingBindings: TDBinding[] = Object.values(this.page.bindings).filter(
(binding) =>
copyingShapeIds.includes(binding.fromId) && copyingShapeIds.includes(binding.toId)
)
const copyingAssets = copyingShapes
.map((shape) => {
if (!shape.assetId) return
return this.document.assets[shape.assetId]
})
.filter(Boolean) as TDAsset[]
return {
shapes: copyingShapes,
bindings: copyingBindings,
assets: copyingAssets,
}
}
/**
* Cut (copy and delete) one or more shapes to the clipboard.
* @param ids The ids of the shapes to cut.
@ -1775,7 +1739,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
e?.preventDefault()
this.clipboard = this.getClipboard(ids)
this.clipboard = this.getContent(ids)
const jsonString = JSON.stringify({
type: 'tldr/clipboard',
@ -1811,97 +1775,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
paste = async (point?: number[], e?: ClipboardEvent) => {
if (this.readOnly) return
const pasteInCurrentPage = (shapes: TDShape[], bindings: TDBinding[], assets: TDAsset[]) => {
const idsMap: Record<string, string> = {}
const newAssets = assets.filter((asset) => this.document.assets[asset.id] === undefined)
if (newAssets.length) {
this.patchState({
document: {
assets: Object.fromEntries(newAssets.map((asset) => [asset.id, asset])),
},
})
}
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,
id: idsMap[shape.id],
parentId: parentShapeId || this.currentPageId,
}
if (shape.children) {
copy.children = shape.children.map((id) => idsMap[id])
}
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))
let center = Vec.toFixed(this.getPagePoint(point || this.centerPoint))
if (
Vec.dist(center, this.pasteInfo.center) < 2 ||
Vec.dist(center, Vec.toFixed(Utils.getBoundsCenter(commonBounds))) < 2
) {
center = Vec.add(center, this.pasteInfo.offset)
this.pasteInfo.offset = Vec.add(this.pasteInfo.offset, [GRID_SIZE, GRID_SIZE])
} else {
this.pasteInfo.center = center
this.pasteInfo.offset = [0, 0]
}
const centeredBounds = Utils.centerBounds(commonBounds, center)
const delta = Vec.sub(
Utils.getBoundsCenter(centeredBounds),
Utils.getBoundsCenter(commonBounds)
)
this.create(
shapesToPaste.map((shape) =>
TLDR.getShapeUtil(shape.type).create({
...shape,
point: Vec.toFixed(Vec.add(shape.point, delta)),
parentId: shape.parentId || this.currentPageId,
})
),
bindingsToPaste
)
}
const pasteTextAsSvg = async (text: string) => {
const div = document.createElement('div')
div.innerHTML = text
@ -1951,7 +1824,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
} = JSON.parse(maybeJson)
if (json.type === 'tldr/clipboard') {
pasteInCurrentPage(json.shapes, json.bindings, json.assets)
this.insertContent(json, { point, select: true })
return
} else {
throw Error('Not tldraw data!')
@ -2058,7 +1931,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
} else {
TLDR.warn('This browser does not support the Clipboard API!')
if (this.clipboard) {
pasteInCurrentPage(this.clipboard.shapes, this.clipboard.bindings, this.clipboard.assets)
this.insertContent(this.clipboard, { point, select: true })
}
}
@ -2076,7 +1949,9 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs')
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
window.focus() // weird but necessary
if (typeof window !== 'undefined') {
window.focus() // weird but necessary
}
if (opts.includeFonts) {
try {
@ -2238,7 +2113,7 @@ export class TldrawApp extends StateManager<TDSnapshot> {
const svgString = TLDR.getSvgString(svg, 1)
this.clipboard = this.getClipboard(ids)
this.clipboard = this.getContent(ids)
const tldrawString = JSON.stringify({
type: 'tldr/clipboard',
@ -2257,23 +2132,104 @@ export class TldrawApp extends StateManager<TDSnapshot> {
return svgString
}
/**
* Get the shapes and bindings for the current selection, if any, or else the current page.
*
* @param ids The ids of the shapes to get content for.
*/
getContent = (ids?: string[]) => {
const page = this.getPage(this.currentPageId)
// If ids is explicitly empty ([]) return
if (ids && ids.length === 0) return
// If ids was not provided, use the selected ids
if (!ids) ids = this.selectedIds
// If there are no selected ids, use all the page's shape ids
if (ids.length === 0) ids = Object.keys(page.shapes)
// If the page was empty, return
if (ids.length === 0) return
const shapes = ids
.map((id) => page.shapes[id])
.flatMap((shape) => [shape, ...(shape.children ?? []).map((childId) => page.shapes[childId])])
.map(deepCopy)
const idsSet = new Set(shapes.map((s) => s.id))
shapes.forEach((shape) => {
if (shape.parentId === this.currentPageId) {
shape.parentId = 'currentPageId'
}
})
// If a binding's from and to are included, then include the binding;
// but if only one shape is included, discard the binding
const bindings = Object.values(page.bindings)
.filter((binding) => {
if (idsSet.has(binding.fromId) && idsSet.has(binding.toId)) {
return true
}
if (idsSet.has(binding.fromId)) {
const shape = shapes.find((s) => s.id === binding.fromId)
const handles = shape!.handles
if (handles) {
Object.values(handles).forEach((handle) => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = undefined
}
})
}
}
if (idsSet.has(binding.toId)) {
const shape = shapes.find((s) => s.id === binding.toId)
const handles = shape!.handles
if (handles) {
Object.values(handles).forEach((handle) => {
if (handle!.bindingId === binding.id) {
handle!.bindingId = undefined
}
})
}
}
return false
})
.map(deepCopy)
const assets = [
...new Set(
shapes
.map((shape) => {
if (!shape.assetId) return
return this.document.assets[shape.assetId]
})
.filter(Boolean)
.map(deepCopy)
),
] as TDAsset[]
return { shapes, bindings, assets }
}
/**
* Copy one or more shapes as JSON.
* @param ids The ids of the shapes to copy.
* @param pageId The page from which to copy the shapes.
* @returns A string containing the JSON.
*/
copyJson = (ids = this.selectedIds, pageId = this.currentPageId) => {
if (ids.length === 0) ids = Object.keys(this.page.shapes)
copyJson = (ids = this.selectedIds) => {
const content = this.getContent(ids)
if (ids.length === 0) return
if (content) {
TLDR.copyStringToClipboard(JSON.stringify(content))
}
const shapes = ids.map((id) => this.getShape(id, pageId))
const json = JSON.stringify(shapes, null, 2)
TLDR.copyStringToClipboard(json)
return json
return this
}
/**
@ -2281,20 +2237,37 @@ export class TldrawApp extends StateManager<TDSnapshot> {
* @param ids The ids of the shapes to copy from the current page.
* @returns A string containing the JSON.
*/
exportJson = (
ids = this.selectedIds.length ? this.selectedIds : Object.keys(this.page.shapes)
exportJson = (ids = this.selectedIds) => {
const content = this.getContent(ids)
if (content) {
const blob = new Blob([JSON.stringify(content)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `export.json`
link.click()
}
return this
}
/**
* Insert content.
*
* @param content The content to insert.
* @param content.shapes An array of TDShape objects.
* @param content.bindings (optional) An array of TDBinding objects.
* @param content.assets (optional) An array of TDAsset objects.
* @param opts (optional) An options object
* @param opts.point (optional) A point at which to paste the content.
* @param opts.select (optional) When true, the inserted shapes will be selected.
*/
insertContent = (
content: { shapes: TDShape[]; bindings?: TDBinding[]; assets?: TDAsset[] },
opts = {} as { point?: number[]; select?: boolean }
) => {
if (ids.length === 0) return
const shapes = ids.map((id) => this.getShape(id, this.currentPageId))
const json = JSON.stringify(shapes, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `export.json`
link.click()
return this.setState(Commands.insertContent(this, content, opts), 'insert_content')
}
/**

View file

@ -1,83 +1,827 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[` 1`] = `
"[
{
\\"id\\": \\"rect1\\",
\\"parentId\\": \\"page1\\",
\\"name\\": \\"Rectangle\\",
\\"childIndex\\": 1,
\\"type\\": \\"rectangle\\",
\\"point\\": [
TldrawTestApp {
"_idbId": undefined,
"_snapshot": Object {
"appState": Object {
"activeTool": "select",
"currentPageId": "page",
"currentStyle": Object {
"color": "black",
"dash": "draw",
"isFilled": false,
"scale": 1,
"size": "small",
},
"disableAssets": false,
"eraseLine": Array [],
"hoveredId": undefined,
"isEmptyCanvas": false,
"isLoading": false,
"isMenuOpen": false,
"isToolLocked": false,
"snapLines": Array [],
"status": "idle",
},
"document": Object {
"assets": Object {},
"id": "doc",
"name": "New Document",
"pageStates": Object {
"page": Object {
"camera": Object {
"point": Array [
0,
0,
],
"zoom": 1,
},
"id": "page",
"selectedIds": Array [],
},
},
"pages": Object {
"page": Object {
"bindings": Object {},
"childIndex": 1,
"id": "page",
"name": "Page 1",
"shapes": Object {},
},
},
"version": 15.3,
},
"settings": Object {
"isCadSelectMode": false,
"isDarkMode": false,
"isDebugMode": false,
"isFocusMode": false,
"isPenMode": false,
"isReadonlyMode": false,
"isSnapping": false,
"isZoomSnap": false,
"keepStyleMenuOpen": false,
"language": "en",
"nudgeDistanceLarge": 16,
"nudgeDistanceSmall": 1,
"showBindingHandles": true,
"showCloneHandles": false,
"showGrid": false,
"showRotateHandles": true,
},
},
"_state": Object {
"appState": Object {
"activeTool": "select",
"currentPageId": "page1",
"currentStyle": Object {
"color": "black",
"dash": "draw",
"isFilled": false,
"scale": 1,
"size": "small",
},
"disableAssets": false,
"eraseLine": Array [],
"hoveredId": undefined,
"isEmptyCanvas": false,
"isLoading": false,
"isMenuOpen": false,
"isToolLocked": false,
"snapLines": Array [],
"status": "idle",
},
"document": Object {
"assets": Object {},
"id": "doc",
"name": "New Document",
"pageStates": Object {
"page1": Object {
"bindingId": undefined,
"camera": Object {
"point": Array [
0,
0,
],
"zoom": 1,
},
"editingId": undefined,
"hoveredId": undefined,
"id": "page1",
"pointedId": undefined,
"selectedIds": Array [
"rect1",
"rect2",
"rect3",
],
},
},
"pages": Object {
"page1": Object {
"bindings": Object {},
"id": "page1",
"shapes": Object {
"rect1": Object {
"childIndex": 1,
"id": "rect1",
"label": "",
"labelPoint": Array [
0.5,
0.5,
],
"name": "Rectangle",
"parentId": "page1",
"point": Array [
0,
0,
],
"size": Array [
100,
100,
],
"style": Object {
"color": "blue",
"dash": "draw",
"size": "medium",
},
"type": "rectangle",
},
"rect2": Object {
"childIndex": 2,
"id": "rect2",
"label": "",
"labelPoint": Array [
0.5,
0.5,
],
"name": "Rectangle",
"parentId": "page1",
"point": Array [
100,
100,
],
"size": Array [
100,
100,
],
"style": Object {
"color": "blue",
"dash": "draw",
"size": "medium",
},
"type": "rectangle",
},
"rect3": Object {
"childIndex": 3,
"id": "rect3",
"label": "",
"labelPoint": Array [
0.5,
0.5,
],
"name": "Rectangle",
"parentId": "page1",
"point": Array [
20,
20,
],
"size": Array [
100,
100,
],
"style": Object {
"color": "blue",
"dash": "draw",
"size": "medium",
},
"type": "rectangle",
},
},
},
},
"version": 15.3,
},
"settings": Object {
"isCadSelectMode": false,
"isDarkMode": false,
"isDebugMode": false,
"isFocusMode": false,
"isPenMode": false,
"isReadonlyMode": false,
"isSnapping": false,
"isZoomSnap": false,
"keepStyleMenuOpen": false,
"language": "en",
"nudgeDistanceLarge": 16,
"nudgeDistanceSmall": 1,
"showBindingHandles": true,
"showCloneHandles": false,
"showGrid": false,
"showRotateHandles": true,
},
},
"_status": "ready",
"addMediaFromFile": [Function],
"addToSelectHistory": [Function],
"align": [Function],
"altKey": false,
"applyPatch": [Function],
"broadcastPageChanges": [Function],
"callbacks": Object {},
"cancel": [Function],
"cancelSession": [Function],
"changePage": [Function],
"cleanup": [Function],
"clearSelectHistory": [Function],
"clickBounds": [Function],
"clickBoundsHandle": [Function],
"clickCanvas": [Function],
"clickShape": [Function],
"completeSession": [Function],
"copy": [Function],
"copyImage": [Function],
"copyJson": [Function],
"copySvg": [Function],
"create": [Function],
"createPage": [Function],
"createShapes": [Function],
"ctrlKey": false,
"currentPoint": Array [
0,
0,
],
"currentTool": SelectTool {
"app": [Circular],
"clonePaint": [Function],
"getNextChildIndex": [Function],
"getShapeClone": [Function],
"onCancel": [Function],
"onDoubleClickBoundsHandle": [Function],
"onDoubleClickCanvas": [Function],
"onDoubleClickHandle": [Function],
"onDoubleClickShape": [Function],
"onEnter": [Function],
"onExit": [Function],
"onHoverShape": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointBounds": [Function],
"onPointBoundsHandle": [Function],
"onPointHandle": [Function],
"onPointShape": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"onReleaseBounds": [Function],
"onReleaseBoundsHandle": [Function],
"onReleaseHandle": [Function],
"onRightPointBounds": [Function],
"onRightPointShape": [Function],
"onShapeClone": [Function],
"onUnhoverShape": [Function],
"setStatus": [Function],
"status": "idle",
"type": "select",
},
"cut": [Function],
"delete": [Function],
"deleteAll": [Function],
"deletePage": [Function],
"distribute": [Function],
"doubleClickBoundHandle": [Function],
"doubleClickShape": [Function],
"duplicate": [Function],
"duplicatePage": [Function],
"editingStartTime": -1,
"expectSelectedIdsToBe": [Function],
"expectShapesToBeAtPoints": [Function],
"expectShapesToHaveProps": [Function],
"exportImage": [Function],
"exportJson": [Function],
"fileSystemHandle": null,
"flipHorizontal": [Function],
"flipVertical": [Function],
"forceUpdate": [Function],
"getAppState": [Function],
"getBinding": [Function],
"getBindings": [Function],
"getContent": [Function],
"getImage": [Function],
"getPage": [Function],
"getPagePoint": [Function],
"getPageState": [Function],
"getReservedContent": [Function],
"getShape": [Function],
"getShapeBounds": [Function],
"getShapeUtil": [Function],
"getShapes": [Function],
"getSvg": [Function],
"getViewboxFromSVG": [Function],
"group": [Function],
"hoverShape": [Function],
"initialState": Object {
"appState": Object {
"activeTool": "select",
"currentPageId": "page",
"currentStyle": Object {
"color": "black",
"dash": "draw",
"isFilled": false,
"scale": 1,
"size": "small",
},
"disableAssets": false,
"eraseLine": Array [],
"hoveredId": undefined,
"isEmptyCanvas": false,
"isLoading": false,
"isMenuOpen": false,
"isToolLocked": false,
"snapLines": Array [],
"status": "idle",
},
"document": Object {
"assets": Object {},
"id": "doc",
"name": "New Document",
"pageStates": Object {
"page": Object {
"camera": Object {
"point": Array [
0,
0,
],
"zoom": 1,
},
"id": "page",
"selectedIds": Array [],
},
},
"pages": Object {
"page": Object {
"bindings": Object {},
"childIndex": 1,
"id": "page",
"name": "Page 1",
"shapes": Object {},
},
},
"version": 15.3,
},
"settings": Object {
"isCadSelectMode": false,
"isDarkMode": false,
"isDebugMode": false,
"isFocusMode": false,
"isPenMode": false,
"isReadonlyMode": false,
"isSnapping": false,
"isZoomSnap": false,
"keepStyleMenuOpen": false,
"language": "en",
"nudgeDistanceLarge": 16,
"nudgeDistanceSmall": 1,
"showBindingHandles": true,
"showCloneHandles": false,
"showGrid": false,
"showRotateHandles": true,
},
},
"insertContent": [Function],
"isCreating": false,
"isDirty": false,
"isForcePanning": false,
"isPaused": false,
"isPointing": false,
"justSent": false,
"loadDocument": [Function],
"loadRoom": [Function],
"mergeDocument": [Function],
"metaKey": false,
"migrate": [Function],
"moveBackward": [Function],
"moveForward": [Function],
"movePointer": [Function],
"moveToBack": [Function],
"moveToFront": [Function],
"moveToPage": [Function],
"newProject": [Function],
"nudge": [Function],
"onCommand": [Function],
"onDoubleClickBounds": [Function],
"onDoubleClickBoundsHandle": [Function],
"onDoubleClickCanvas": [Function],
"onDoubleClickHandle": [Function],
"onDoubleClickShape": [Function],
"onDragBounds": [Function],
"onDragBoundsHandle": [Function],
"onDragCanvas": [Function],
"onDragHandle": [Function],
"onDragOver": [Function],
"onDragShape": [Function],
"onDrop": [Function],
"onError": [Function],
"onHoverBounds": [Function],
"onHoverBoundsHandle": [Function],
"onHoverHandle": [Function],
"onHoverShape": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPan": [Function],
"onPatch": [Function],
"onPersist": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointBounds": [Function],
"onPointBoundsHandle": [Function],
"onPointCanvas": [Function],
"onPointHandle": [Function],
"onPointShape": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"onReady": [Function],
"onRedo": [Function],
"onReleaseBounds": [Function],
"onReleaseBoundsHandle": [Function],
"onReleaseCanvas": [Function],
"onReleaseHandle": [Function],
"onReleaseShape": [Function],
"onRenderCountChange": [Function],
"onReplace": [Function],
"onRightPointBounds": [Function],
"onRightPointBoundsHandle": [Function],
"onRightPointCanvas": [Function],
"onRightPointHandle": [Function],
"onRightPointShape": [Function],
"onShapeBlur": [Function],
"onShapeChange": [Function],
"onShapeClone": [Function],
"onStateDidChange": [Function],
"onUndo": [Function],
"onUnhoverBounds": [Function],
"onUnhoverBoundsHandle": [Function],
"onUnhoverHandle": [Function],
"onUnhoverShape": [Function],
"onZoom": [Function],
"openAsset": [Function],
"openProject": [Function],
"originPoint": Array [
0,
0,
],
"pan": [Function],
"paste": [Function],
"pasteInfo": Object {
"center": Array [
0,
0,
0
],
\\"size\\": [
100,
100
"offset": Array [
0,
0,
],
\\"style\\": {
\\"dash\\": \\"draw\\",
\\"size\\": \\"medium\\",
\\"color\\": \\"blue\\"
},
\\"label\\": \\"\\",
\\"labelPoint\\": [
0.5,
0.5
]
},
{
\\"id\\": \\"rect2\\",
\\"parentId\\": \\"page1\\",
\\"name\\": \\"Rectangle\\",
\\"childIndex\\": 2,
\\"type\\": \\"rectangle\\",
\\"point\\": [
100,
100
],
\\"size\\": [
100,
100
],
\\"style\\": {
\\"dash\\": \\"draw\\",
\\"size\\": \\"medium\\",
\\"color\\": \\"blue\\"
},
\\"label\\": \\"\\",
\\"labelPoint\\": [
0.5,
0.5
]
"patchCreate": [Function],
"patchState": [Function],
"persist": [Function],
"pinchZoom": [Function],
"pointBounds": [Function],
"pointBoundsHandle": [Function],
"pointCanvas": [Function],
"pointShape": [Function],
"pointer": -1,
"pressKey": [Function],
"prevAssets": Object {},
"prevBindings": Object {},
"prevSelectedIds": Array [],
"prevShapes": Object {},
"previousPoint": Array [
0,
0,
],
"readOnly": false,
"ready": Promise {},
"redo": [Function],
"redoSelect": [Function],
"refreshBoundingBoxes": [Function],
"releaseKey": [Function],
"removeUser": [Function],
"renamePage": [Function],
"rendererBounds": Object {
"height": 100,
"maxX": 100,
"maxY": 100,
"minX": 0,
"minY": 0,
"width": 100,
},
{
\\"id\\": \\"rect3\\",
\\"parentId\\": \\"page1\\",
\\"name\\": \\"Rectangle\\",
\\"childIndex\\": 3,
\\"type\\": \\"rectangle\\",
\\"point\\": [
20,
20
"replaceHistory": [Function],
"replacePageContent": [Function],
"replaceState": [Function],
"reset": [Function],
"resetBounds": [Function],
"resetCamera": [Function],
"resetDocument": [Function],
"resetHistory": [Function],
"resetZoom": [Function],
"rotate": [Function],
"rotationInfo": Object {
"center": Array [
0,
0,
],
\\"size\\": [
100,
100
"selectedIds": Array [],
},
"saveProject": [Function],
"saveProjectAs": [Function],
"select": [Function],
"selectAll": [Function],
"selectHistory": Object {
"pointer": 1,
"stack": Array [
Array [],
Array [
"rect1",
"rect2",
"rect3",
],
],
\\"style\\": {
\\"dash\\": \\"draw\\",
\\"size\\": \\"medium\\",
\\"color\\": \\"blue\\"
},
"selectNone": [Function],
"selectTool": [Function],
"session": undefined,
"setCamera": [Function],
"setDisableAssets": [Function],
"setEditingId": [Function],
"setHoveredId": [Function],
"setIsLoading": [Function],
"setMenuOpen": [Function],
"setSelectedIds": [Function],
"setSetting": [Function],
"setShapeProps": [Function],
"setSnapshot": [Function],
"setState": [Function],
"shiftKey": false,
"signOut": [Function],
"spaceKey": false,
"stack": Array [],
"startSession": [Function],
"stopPointing": [Function],
"store": Object {
"destroy": [Function],
"getState": [Function],
"setState": [Function],
"subscribe": [Function],
},
"stretch": [Function],
"style": [Function],
"toggleAspectRatioLocked": [Function],
"toggleDarkMode": [Function],
"toggleDebugMode": [Function],
"toggleDecoration": [Function],
"toggleFocusMode": [Function],
"toggleGrid": [Function],
"toggleHidden": [Function],
"toggleLocked": [Function],
"togglePenMode": [Function],
"toggleToolLock": [Function],
"toggleZoomSnap": [Function],
"tools": Object {
"arrow": ArrowTool {
"app": [Circular],
"getNextChildIndex": [Function],
"onCancel": [Function],
"onEnter": [Function],
"onExit": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"setStatus": [Function],
"status": "idle",
"type": "arrow",
},
\\"label\\": \\"\\",
\\"labelPoint\\": [
0.5,
0.5
]
}
]"
"draw": DrawTool {
"app": [Circular],
"getNextChildIndex": [Function],
"onCancel": [Function],
"onEnter": [Function],
"onExit": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"setStatus": [Function],
"status": "idle",
"type": "draw",
},
"ellipse": EllipseTool {
"app": [Circular],
"getNextChildIndex": [Function],
"onCancel": [Function],
"onEnter": [Function],
"onExit": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"setStatus": [Function],
"status": "idle",
"type": "ellipse",
},
"erase": EraseTool {
"app": [Circular],
"getNextChildIndex": [Function],
"onCancel": [Function],
"onEnter": [Function],
"onExit": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"setStatus": [Function],
"status": "idle",
"type": "erase",
},
"line": LineTool {
"app": [Circular],
"getNextChildIndex": [Function],
"onCancel": [Function],
"onEnter": [Function],
"onExit": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"setStatus": [Function],
"status": "idle",
"type": "line",
},
"rectangle": RectangleTool {
"app": [Circular],
"getNextChildIndex": [Function],
"onCancel": [Function],
"onEnter": [Function],
"onExit": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"setStatus": [Function],
"status": "idle",
"type": "rectangle",
},
"select": SelectTool {
"app": [Circular],
"clonePaint": [Function],
"getNextChildIndex": [Function],
"getShapeClone": [Function],
"onCancel": [Function],
"onDoubleClickBoundsHandle": [Function],
"onDoubleClickCanvas": [Function],
"onDoubleClickHandle": [Function],
"onDoubleClickShape": [Function],
"onEnter": [Function],
"onExit": [Function],
"onHoverShape": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointBounds": [Function],
"onPointBoundsHandle": [Function],
"onPointHandle": [Function],
"onPointShape": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"onReleaseBounds": [Function],
"onReleaseBoundsHandle": [Function],
"onReleaseHandle": [Function],
"onRightPointBounds": [Function],
"onRightPointShape": [Function],
"onShapeClone": [Function],
"onUnhoverShape": [Function],
"setStatus": [Function],
"status": "idle",
"type": "select",
},
"sticky": StickyTool {
"app": [Circular],
"getNextChildIndex": [Function],
"onCancel": [Function],
"onEnter": [Function],
"onExit": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"setStatus": [Function],
"status": "idle",
"type": "sticky",
},
"text": TextTool {
"app": [Circular],
"getNextChildIndex": [Function],
"onCancel": [Function],
"onEnter": [Function],
"onExit": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointShape": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"onShapeBlur": [Function],
"setStatus": [Function],
"status": "idle",
"stopEditingShape": [Function],
"type": "text",
},
"triangle": TriangleTool {
"app": [Circular],
"getNextChildIndex": [Function],
"onCancel": [Function],
"onEnter": [Function],
"onExit": [Function],
"onKeyDown": [Function],
"onKeyUp": [Function],
"onPinch": [Function],
"onPinchEnd": [Function],
"onPinchStart": [Function],
"onPointerDown": [Function],
"onPointerMove": [Function],
"onPointerUp": [Function],
"setStatus": [Function],
"status": "idle",
"type": "triangle",
},
},
"undo": [Function],
"undoSelect": [Function],
"ungroup": [Function],
"updateBounds": [Function],
"updateDocument": [Function],
"updateInputs": [Function],
"updateSession": [Function],
"updateShapes": [Function],
"updateUsers": [Function],
"updateViewport": [Function],
"useStore": [Function],
"viewport": Object {
"height": 100,
"maxX": 100,
"maxY": 100,
"minX": 0,
"minY": 0,
"width": 100,
},
"zoomBy": [Function],
"zoomIn": [Function],
"zoomOut": [Function],
"zoomTo": [Function],
"zoomToContent": [Function],
"zoomToFit": [Function],
"zoomToSelection": [Function],
}
`;
exports[`TldrawTestApp Exposes undo/redo stack: history 1`] = `

View file

@ -22,3 +22,4 @@ export * from './translateShapes'
export * from './ungroupShapes'
export * from './updateShapes'
export * from './setShapesProps'
export * from './insertContent'

View file

@ -0,0 +1 @@
export * from './insertContent'

View file

@ -0,0 +1,187 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Utils } from '@tldraw/core'
import { TLDR } from '~state/TLDR'
import { mockDocument, TldrawTestApp } from '~test'
import { ColorStyle, DashStyle, SessionType, SizeStyle, TDShapeType } from '~types'
let app: TldrawTestApp
beforeEach(() => {
app = new TldrawTestApp()
app.loadDocument(mockDocument)
})
describe('insert command', () => {
it('Inserts shapes, bindings, etc. into the current page', () => {
const content = app.getContent()!
const size = app.shapes.length
app.insertContent(content)
expect(app.shapes.length).toBe(size * 2)
})
it('Selects content when opts.select is true', () => {
const content = app.getContent()!
const size = app.shapes.length
const prevSelectedIds = [...app.selectedIds]
app.insertContent(content, { select: true })
expect(app.shapes.length).toBe(size * 2)
expect(app.selectedIds).not.toMatchObject(prevSelectedIds)
})
it('Centers inserted content at a point', () => {
app.select('rect1')
const content = app.getContent()!
const before = [...app.shapes]
const point = [222, 444]
app.insertContent(content, { point })
const inserted = [...app.shapes].filter((s) => !before.includes(s))
expect(
Utils.getBoundsCenter(Utils.getCommonBounds(inserted.map(TLDR.getBounds)))
).toMatchObject(point)
})
it('does nothing when ids are explicitly empty', () => {
const content = app.getContent([])
expect(content).toBe(undefined)
})
it('uses the selected ids when no ids provided', () => {
app.select('rect1')
const content = app.getContent()!
const size = app.shapes.length
app.insertContent(content)
expect(app.shapes.length).toBe(size + 1)
})
it('uses all shape ids from the page when no selection, either', () => {
app.selectNone()
const content = app.getContent()!
const size = app.shapes.length
app.insertContent(content)
expect(app.shapes.length).toBe(size * 2)
})
it('does nothing if the page has no shapes, either', () => {
app.deleteAll()
const content = app.getContent()
expect(content).toBe(undefined)
})
it('includes bindings', () => {
app
.createShapes({
type: TDShapeType.Arrow,
id: 'arrow1',
point: [200, 200],
})
.select('arrow1')
.movePointer([200, 200])
.startSession(SessionType.Arrow, 'arrow1', 'start')
.movePointer([50, 50])
.completeSession()
.selectNone()
expect(app.bindings.length).toBe(1)
const content = app.getContent()!
const size = app.shapes.length
app.insertContent(content)
expect(app.bindings.length).toBe(2)
expect(app.shapes.length).toBe(size * 2)
})
it('removes bindings when only one shape is inserted', () => {
app
.createShapes({
type: TDShapeType.Arrow,
id: 'arrow1',
point: [200, 200],
})
.select('arrow1')
.movePointer([200, 200])
.startSession(SessionType.Arrow, 'arrow1', 'start')
.movePointer([50, 50])
.completeSession()
expect(app.bindings.length).toBe(1) // arrow1 -> rect3
app.select('rect3') // select only rect3, not arrow1
const content = app.getContent()!
// getContent does not include the incomplete binding
expect(Object.values(content.bindings).length).toBe(0)
app.insertContent(content)
// insertContent does not paste in the discarded binding
expect(app.bindings.length).toBe(1)
})
it('works with groups', () => {
app.select('rect1', 'rect2').group().selectAll()
const content = app.getContent()!
const size = app.shapes.length
app.insertContent(content)
expect(app.shapes.length).toBe(size * 2)
})
it('if a shapes parent is not inserted, inserts to the page instead', () => {
app.select('rect1', 'rect2').group().select('rect1')
const content = app.getContent()!
// insertContent discards the incomplete binding
const size = app.shapes.length
const before = [...app.shapes]
app.insertContent(content)
expect(app.shapes.length).toBe(size + 1)
const inserted = [...app.shapes].filter((s) => !before.includes(s))[0]
expect(inserted.parentId).toBe(app.currentPageId)
})
it('does not add groups without children', () => {
// insertContent discards the incomplete binding
const size = app.shapes.length
app.insertContent({
shapes: [
{
id: '935ff424-bf40-4e2d-3bfb-d26061150b03',
type: TDShapeType.Group,
name: 'Group',
parentId: 'currentPageId',
childIndex: 1,
point: [0, 0],
size: [100, 100],
rotation: 0,
children: [],
style: {
color: ColorStyle.Black,
size: SizeStyle.Small,
isFilled: false,
dash: DashStyle.Dashed,
scale: 1,
},
},
],
})
expect(app.shapes.length).toBe(size)
})
})

View file

@ -0,0 +1,251 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Utils } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { GRID_SIZE } from '~constants'
import { TLDR } from '~state/TLDR'
import type { PagePartial, TldrawCommand, TDShape, TDBinding, TDAsset } from '~types'
import type { TldrawApp } from '../../internal'
export function insertContent(
app: TldrawApp,
content: { shapes: TDShape[]; bindings?: TDBinding[]; assets?: TDAsset[] },
opts = {} as { point?: number[]; select?: boolean }
): TldrawCommand {
const { currentPageId } = app
const before: PagePartial = {
shapes: {},
bindings: {},
}
const after: PagePartial = {
shapes: {},
bindings: {},
}
const oldToNewIds: Record<string, string> = {}
// The index of the new shape
let nextIndex = TLDR.getTopChildIndex(app.state, currentPageId)
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 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[]
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 (
!(
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))
})
}
}
}
return {
id: 'insert',
before: {
document: {
pages: {
[currentPageId]: before,
},
pageStates: {
[currentPageId]: { selectedIds: [...app.selectedIds] },
},
},
},
after: {
document: {
pages: {
[currentPageId]: after,
},
assets: content.assets
? Object.fromEntries(
content.assets
.filter((asset) => !app.document.assets[asset.id])
.map((asset) => [asset.id, asset])
)
: {},
pageStates: {
[currentPageId]: {
selectedIds: select ? Object.keys(after.shapes) : [...app.selectedIds],
},
},
},
},
}
}