From 06f2dcd44b8b81f97144d3ebbd5c47b67b941c38 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 6 May 2022 09:14:20 +0100 Subject: [PATCH] [improvement] paste media from clipboard (#667) * Adds support for pasting images * Make pasted shape fit in screen * default to debug mode false --- .../tldraw/src/hooks/useKeyboardShortcuts.tsx | 22 +++++ packages/tldraw/src/state/TldrawApp.ts | 84 +++++++++++++++++-- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx index 526bfbd05..ef3cacbfd 100644 --- a/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx +++ b/packages/tldraw/src/hooks/useKeyboardShortcuts.tsx @@ -15,6 +15,27 @@ export function useKeyboardShortcuts(ref: React.RefObject) { [ref] ) + React.useEffect(() => { + if (!app) return + const handlePaste = (e: ClipboardEvent) => { + let items = e.clipboardData?.items ?? [] + for (var index in items) { + var item = items[index] + if (item.kind === 'file') { + var file = item.getAsFile() + if (file) { + app.addMediaFromFile(file) + } + } + } + } + + document.addEventListener('paste', handlePaste) + return () => { + document.removeEventListener('paste', handlePaste) + } + }, [app]) + /* ---------------------- Tools --------------------- */ useHotkeys( @@ -523,6 +544,7 @@ export function useKeyboardShortcuts(ref: React.RefObject) { '⌘+v,ctrl+v', () => { if (!canHandleEvent()) return + app.paste() }, undefined, diff --git a/packages/tldraw/src/state/TldrawApp.ts b/packages/tldraw/src/state/TldrawApp.ts index 007b16796..846a27dd9 100644 --- a/packages/tldraw/src/state/TldrawApp.ts +++ b/packages/tldraw/src/state/TldrawApp.ts @@ -1764,9 +1764,12 @@ export class TldrawApp extends StateManager { */ paste = (point?: number[]) => { 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: { @@ -1774,25 +1777,33 @@ export class TldrawApp extends StateManager { }, }) } + 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) { @@ -1802,14 +1813,18 @@ export class TldrawApp extends StateManager { } 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 @@ -1820,11 +1835,13 @@ export class TldrawApp extends StateManager { 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({ @@ -1854,11 +1871,16 @@ export class TldrawApp extends StateManager { bindings: TDBinding[] assets: TDAsset[] } = JSON.parse(result) + if (data.type === 'tldr/clipboard') { pasteInCurrentPage(data.shapes, data.bindings, data.assets) } else { + console.log(data.type) + TLDR.warn('The selected shape was not a tldraw shape, treating as text.') + const shapeId = Utils.uniqueId() + this.createShapes({ id: shapeId, type: TDShapeType.Text, @@ -1867,15 +1889,18 @@ export class TldrawApp extends StateManager { point: this.getPagePoint(this.centerPoint, this.currentPageId), style: { ...this.appState.currentStyle }, }) + this.select(shapeId) } }) .catch(() => { - TLDR.warn('Read permissions denied!') + // No text on clipboard or read permissions denied. + if (this.clipboard) { pasteInCurrentPage(this.clipboard.shapes, this.clipboard.bindings, this.clipboard.assets) } }) + return this } @@ -2555,6 +2580,26 @@ export class TldrawApp extends StateManager { const Shape = shapeUtils[type] + // Ensure that the pasted shape fits inside of the current viewport + + if (size[0] > this.viewport.width) { + let r = size[1] / size[0] + size[0] = this.viewport.width - FIT_TO_SCREEN_PADDING + size[1] = size[0] * r + if (size[1] < 32 || size[1] < 32) { + size[1] = 32 + size[0] = size[1] / r + } + } else if (size[1] > this.viewport.height) { + let r = size[0] / size[1] + size[1] = this.viewport.height - FIT_TO_SCREEN_PADDING + size[0] = size[1] * r + if (size[1] < 32 || size[1] < 32) { + size[0] = 32 + size[1] = size[0] / r + } + } + const newShape = Shape.create({ id, parentId: currentPageId, @@ -2566,7 +2611,9 @@ export class TldrawApp extends StateManager { }) const bounds = Shape.getBounds(newShape as never) + newShape.point = Vec.sub(newShape.point, [bounds.width / 2, bounds.height / 2]) + this.createShapes(newShape) return this @@ -2874,7 +2921,11 @@ export class TldrawApp extends StateManager { if (groups.length === 0) return this const command = Commands.ungroupShapes(this, ids, groups as GroupShape[], pageId) - if (!command) return this + + if (!command) { + return this + } + return this.setState(command) } @@ -2883,26 +2934,37 @@ export class TldrawApp extends StateManager { */ cancel = (): this => { this.currentTool.onCancel?.() + return this } - private addMediaFromFile = async (file: File, point = this.centerPoint) => { + addMediaFromFile = async (file: File, point = this.centerPoint) => { this.setIsLoading(true) + + console.log(this.viewport.width) + const id = Utils.uniqueId() const pagePoint = this.getPagePoint(point) const extension = file.name.match(/\.[0-9a-z]+$/i) + if (!extension) throw Error('No extension') + const isImage = IMAGE_EXTENSIONS.includes(extension[0].toLowerCase()) const isVideo = VIDEO_EXTENSIONS.includes(extension[0].toLowerCase()) + if (!(isImage || isVideo)) throw Error('Wrong extension') + const shapeType = isImage ? TDShapeType.Image : TDShapeType.Video const assetType = isImage ? TDAssetType.Image : TDAssetType.Video + let src: string | ArrayBuffer | null try { if (this.callbacks.onAssetCreate) { const result = await this.callbacks.onAssetCreate(file, id) + if (!result) throw Error('Asset creation callback returned false') + src = result } else { src = await fileToBase64(file) @@ -2916,6 +2978,7 @@ export class TldrawApp extends StateManager { let viewBox: string[] const svgString = await fileToText(file) const viewBoxAttribute = this.getViewboxFromSVG(svgString) + if (viewBoxAttribute) { viewBox = viewBoxAttribute.split(' ') size[0] = parseFloat(viewBox[2]) @@ -2932,15 +2995,19 @@ export class TldrawApp extends StateManager { const match = Object.values(this.document.assets).find( (asset) => asset.type === assetType && asset.src === src ) + let assetId: string + if (!match) { assetId = Utils.uniqueId() + const asset = { id: assetId, type: assetType, src, size, } + this.patchState({ document: { assets: { @@ -2948,7 +3015,10 @@ export class TldrawApp extends StateManager { }, }, }) - } else assetId = match.id + } else { + assetId = match.id + } + this.createImageOrVideoShapeAtPoint(id, shapeType, pagePoint, size, assetId) } } catch (error) { @@ -2963,12 +3033,16 @@ export class TldrawApp extends StateManager { private getViewboxFromSVG = (svgStr: string | ArrayBuffer | null) => { const viewBoxRegex = /.*?viewBox=["'](-?[\d.]+[, ]+-?[\d.]+[, ][\d.]+[, ][\d.]+)["']/ + if (typeof svgStr === 'string') { const matches = svgStr.match(viewBoxRegex) return matches && matches.length >= 2 ? matches[1] : null } + console.warn('could not get viewbox from svg string') + this.setIsLoading(false) + return null } @@ -3684,7 +3758,7 @@ export class TldrawApp extends StateManager { isZoomSnap: false, isFocusMode: false, isSnapping: false, - isDebugMode: process.env.NODE_ENV === 'development', + isDebugMode: false, isReadonlyMode: false, nudgeDistanceLarge: 16, nudgeDistanceSmall: 1,