From 6d91916804e656a9dbfdaa05c18a7e2997f1b4e8 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Sun, 19 Jun 2022 14:47:43 +0100 Subject: [PATCH] [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 --- packages/tldraw/src/state/TldrawApp.spec.ts | 3 + packages/tldraw/src/state/TldrawApp.ts | 283 +++--- .../__snapshots__/TldrawApp.spec.ts.snap | 882 ++++++++++++++++-- packages/tldraw/src/state/commands/index.ts | 1 + .../src/state/commands/insertContent/index.ts | 1 + .../insertContent/insertContent.spec.ts | 187 ++++ .../commands/insertContent/insertContent.ts | 251 +++++ 7 files changed, 1384 insertions(+), 224 deletions(-) create mode 100644 packages/tldraw/src/state/commands/insertContent/index.ts create mode 100644 packages/tldraw/src/state/commands/insertContent/insertContent.spec.ts create mode 100644 packages/tldraw/src/state/commands/insertContent/insertContent.ts diff --git a/packages/tldraw/src/state/TldrawApp.spec.ts b/packages/tldraw/src/state/TldrawApp.spec.ts index e89be10e2..e8fe1ff03 100644 --- a/packages/tldraw/src/state/TldrawApp.spec.ts +++ b/packages/tldraw/src/state/TldrawApp.spec.ts @@ -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', () => { diff --git a/packages/tldraw/src/state/TldrawApp.ts b/packages/tldraw/src/state/TldrawApp.ts index 68c1850b5..2a99d6089 100644 --- a/packages/tldraw/src/state/TldrawApp.ts +++ b/packages/tldraw/src/state/TldrawApp.ts @@ -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 { /* 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 { 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 { paste = async (point?: number[], e?: ClipboardEvent) => { if (this.readOnly) return - const pasteInCurrentPage = (shapes: TDShape[], bindings: TDBinding[], assets: TDAsset[]) => { - const idsMap: Record = {} - - 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 { } = 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 { } 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 { 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 { 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 { 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 { * @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') } /** diff --git a/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap b/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap index fcd1a08ad..b1ed04f06 100644 --- a/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap +++ b/packages/tldraw/src/state/__snapshots__/TldrawApp.spec.ts.snap @@ -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`] = ` diff --git a/packages/tldraw/src/state/commands/index.ts b/packages/tldraw/src/state/commands/index.ts index 3fd73c8e4..86b493ddb 100644 --- a/packages/tldraw/src/state/commands/index.ts +++ b/packages/tldraw/src/state/commands/index.ts @@ -22,3 +22,4 @@ export * from './translateShapes' export * from './ungroupShapes' export * from './updateShapes' export * from './setShapesProps' +export * from './insertContent' diff --git a/packages/tldraw/src/state/commands/insertContent/index.ts b/packages/tldraw/src/state/commands/insertContent/index.ts new file mode 100644 index 000000000..726223ee8 --- /dev/null +++ b/packages/tldraw/src/state/commands/insertContent/index.ts @@ -0,0 +1 @@ +export * from './insertContent' diff --git a/packages/tldraw/src/state/commands/insertContent/insertContent.spec.ts b/packages/tldraw/src/state/commands/insertContent/insertContent.spec.ts new file mode 100644 index 000000000..5b790a82b --- /dev/null +++ b/packages/tldraw/src/state/commands/insertContent/insertContent.spec.ts @@ -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) + }) +}) diff --git a/packages/tldraw/src/state/commands/insertContent/insertContent.ts b/packages/tldraw/src/state/commands/insertContent/insertContent.ts new file mode 100644 index 000000000..04c77d33c --- /dev/null +++ b/packages/tldraw/src/state/commands/insertContent/insertContent.ts @@ -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 = {} + + // 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() + + // 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], + }, + }, + }, + }, + } +}