浏览代码

feat: copy & paste shapes

Peng Xiao 3 年之前
父节点
当前提交
ac9cc48fe8

+ 83 - 25
tldraw/apps/tldraw-logseq/src/hooks/usePaste.ts

@@ -1,4 +1,11 @@
-import { fileToBase64, getSizeFromSrc, TLAsset, uniqueId } from '@tldraw/core'
+import {
+  BoundsUtils,
+  fileToBase64,
+  getSizeFromSrc,
+  TLAsset,
+  TLShapeModel,
+  uniqueId,
+} from '@tldraw/core'
 import type { TLReactCallbacks } from '@tldraw/react'
 import * as React from 'react'
 import type { Shape } from '~lib'
@@ -10,37 +17,82 @@ export function usePaste() {
       size: number[]
     }
 
-    // TODO: supporting other pasting formats
     const assetsToCreate: ImageAsset[] = []
+    const shapesToCreate: TLShapeModel[] = []
+
+    async function handleImage(item: ClipboardItem) {
+      const firstImageType = item.types.find(type => type.startsWith('image'))
+      if (firstImageType) {
+        const blob = await item.getType(firstImageType)
+        const dataurl = await fileToBase64(blob)
+        if (typeof dataurl !== 'string') return false
+        const existingAsset = Object.values(app.assets).find(asset => asset.src === dataurl)
+        if (existingAsset) {
+          assetsToCreate.push(existingAsset as ImageAsset)
+          return false
+        }
+        // Create a new asset for this image
+        const asset: ImageAsset = {
+          id: assetId,
+          type: 'image',
+          src: dataurl,
+          size: await getSizeFromSrc(dataurl),
+        }
+        assetsToCreate.push(asset)
+        return true
+      }
+      return false
+    }
+
+    async function handleLogseqShapes(item: ClipboardItem) {
+      const plainTextType = item.types.find(type => type.startsWith('text/plain'))
+      if (plainTextType) {
+        const blob = await item.getType(plainTextType)
+        const rawText = await blob.text()
+        const data = JSON.parse(rawText)
+        if (data.type === 'logseq/whiteboard-shapes') {
+          const shapes = data.shapes as TLShapeModel[]
+          const commonBounds = BoundsUtils.getCommonBounds(
+            shapes.map(shape => ({
+              minX: shape.point?.[0] ?? point[0],
+              minY: shape.point?.[1] ?? point[1],
+              width: shape.size?.[0] ?? 4,
+              height: shape.size?.[1] ?? 4,
+              maxX: (shape.point?.[0] ?? point[0]) + (shape.size?.[0] ?? 4),
+              maxY: (shape.point?.[1] ?? point[1]) + (shape.size?.[1] ?? 4),
+            }))
+          )
+          const clonedShape = data.shapes.map((shape: TLShapeModel) => {
+            return {
+              ...shape,
+              handles: {}, // TODO: may add this later?
+              id: uniqueId(),
+              parentId: app.currentPageId,
+              point: [
+                point[0] + shape.point![0] - commonBounds.minX,
+                point[1] + shape.point![1] - commonBounds.minY,
+              ],
+            }
+          })
+          shapesToCreate.push(...clonedShape)
+        }
+      }
+    }
+
+    // TODO: supporting other pasting formats
     for (const item of await navigator.clipboard.read()) {
       try {
-        const firstImageType = item.types.find(type => type.startsWith('image'))
-        if (firstImageType) {
-          const blob = await item.getType(firstImageType)
-          const dataurl = await fileToBase64(blob)
-          if (typeof dataurl !== 'string') continue
-          const existingAsset = Object.values(app.assets).find(asset => asset.src === dataurl)
-          if (existingAsset) {
-            assetsToCreate.push(existingAsset as ImageAsset)
-            continue
-          }
-          // Create a new asset for this image
-          const asset: ImageAsset = {
-            id: assetId,
-            type: 'image',
-            src: dataurl,
-            size: await getSizeFromSrc(dataurl),
-          }
-          assetsToCreate.push(asset)
+        let handled = await handleImage(item)
+        if (!handled) {
+          await handleLogseqShapes(item)
         }
       } catch (error) {
         console.error(error)
       }
     }
 
-    app.createAssets(assetsToCreate)
-    app.createShapes(
-      assetsToCreate.map((asset, i) => ({
+    const allShapesToAdd = [
+      ...assetsToCreate.map((asset, i) => ({
         id: uniqueId(),
         type: 'image',
         parentId: app.currentPageId,
@@ -48,7 +100,13 @@ export function usePaste() {
         size: asset.size,
         assetId: asset.id,
         opacity: 1,
-      }))
-    )
+      })),
+      ...shapesToCreate,
+    ]
+
+    app.createAssets(assetsToCreate)
+    app.createShapes(allShapesToAdd)
+
+    app.setSelectedShapes(allShapesToAdd.map(s => s.id))
   }, [])
 }

+ 7 - 19
tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx

@@ -3,6 +3,7 @@ import { MagnifyingGlassIcon } from '@radix-ui/react-icons'
 import { TLBounds, TLBoxShape, TLBoxShapeProps } from '@tldraw/core'
 import { HTMLContainer, TLComponentProps, TLContextBarProps, useApp } from '@tldraw/react'
 import { makeObservable, transaction } from 'mobx'
+import { useGesture } from '@use-gesture/react'
 import { observer } from 'mobx-react-lite'
 import * as React from 'react'
 import { ColorInput } from '~components/inputs/ColorInput'
@@ -30,32 +31,18 @@ const LogseqQuickSearch = observer(({ onChange }: LogseqQuickSearchProps) => {
   const rInput = React.useRef<HTMLInputElement>(null)
   const { search } = React.useContext(LogseqContext)
 
-  const secretPrefix = 'œ::'
-
   const commitChange = React.useCallback((id: string) => {
     setQ(id)
     onChange(id)
     rInput.current?.blur()
   }, [])
 
-  const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-    const _q = e.currentTarget.value
-    if (_q.startsWith(secretPrefix)) {
-      const id = _q.substring(secretPrefix.length)
-      commitChange(id)
-    } else {
-      setQ(_q)
-    }
-  }, [])
-
   const options = React.useMemo(() => {
-    if (search && q) {
-      return search(q)
-    }
-    return null
+    return search?.(q)
   }, [search, q])
 
   React.useEffect(() => {
+    // autofocus seems not to be working
     setTimeout(() => {
       rInput.current?.focus()
     })
@@ -71,7 +58,7 @@ const LogseqQuickSearch = observer(({ onChange }: LogseqQuickSearchProps) => {
             type="text"
             value={q}
             placeholder="Search or create page"
-            onChange={handleChange}
+            onChange={q => setQ(q.target.value)}
             onKeyDown={e => {
               if (e.key === 'Enter') {
                 commitChange(q)
@@ -189,6 +176,7 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
     const stop = React.useCallback(
       e => {
         if (!tlEventsEnabled) {
+          // TODO: pinching inside Logseq Shape issue
           e.stopPropagation()
         }
       },
@@ -213,7 +201,7 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
       }
     }, [isActivated])
 
-    const commitChange = React.useCallback((id: string) => {
+    const onPageNameChanged = React.useCallback((id: string) => {
       transaction(() => {
         this.update({
           pageId: id,
@@ -248,7 +236,7 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
           }}
         >
           {this.draft ? (
-            <LogseqQuickSearch onChange={commitChange} />
+            <LogseqQuickSearch onChange={onPageNameChanged} />
           ) : (
             <div
               className="tl-logseq-portal-container"

+ 18 - 2
tldraw/packages/core/src/lib/TLApp/TLApp.ts

@@ -217,7 +217,7 @@ export class TLApp<
       currentPageId: this.currentPageId,
       selectedIds: Array.from(this.selectedIds.values()),
       pages: Array.from(this.pages.values()).map(page => page.serialized),
-      assets: Object.values(this.assets)
+      assets: Object.values(this.assets),
     }
   }
 
@@ -346,9 +346,23 @@ export class TLApp<
     return this
   }
 
+  copy = () => {
+    if (this.selectedShapesArray.length > 0) {
+      const tldrawString = JSON.stringify({
+        type: 'logseq/whiteboard-shapes',
+        shapes: this.selectedShapesArray.map(shape => shape.serialized),
+      })
+      navigator.clipboard.write([
+        new ClipboardItem({
+          'text/plain': new Blob([tldrawString], { type: 'text/plain' }),
+        }),
+      ])
+    }
+  }
+
   paste = (e?: ClipboardEvent) => {
     this.notify('paste', {
-      point: this.inputs.currentPoint
+      point: this.inputs.currentPoint,
     })
     // This callback may be over-written manually, see useSetup.ts in React.
     return void null
@@ -607,6 +621,7 @@ export class TLApp<
     const { selectedShapesArray } = this
     return (
       this.isIn('select') &&
+      !this.isInAny('select.translating', 'select.pinching') &&
       ((selectedShapesArray.length === 1 && !selectedShapesArray[0]?.hideSelection) ||
         selectedShapesArray.length > 1)
     )
@@ -615,6 +630,7 @@ export class TLApp<
   @computed get showSelectionDetail() {
     return (
       this.isIn('select') &&
+      !this.isInAny('select.translating', 'select.pinching') &&
       this.selectedShapes.size > 0 &&
       !this.selectedShapesArray.every(shape => shape.hideSelectionDetail)
     )

+ 1 - 0
tldraw/packages/core/src/lib/shapes/TLShape/TLShape.tsx

@@ -29,6 +29,7 @@ export interface TLShapeProps {
   parentId: string
   name?: string
   point: number[]
+  size?: number[]
   scale?: number[]
   rotation?: number
   handles?: Record<string, TLHandle>

+ 0 - 20
tldraw/packages/react/src/hooks/useCanvasEvents.ts

@@ -74,23 +74,3 @@ export function useCanvasEvents() {
 
   return events
 }
-
-function fileToBase64(file: Blob): Promise<string | ArrayBuffer | null> {
-  return new Promise((resolve, reject) => {
-    if (file) {
-      const reader = new FileReader()
-      reader.readAsDataURL(file)
-      reader.onload = () => resolve(reader.result)
-      reader.onerror = error => reject(error)
-      reader.onabort = error => reject(error)
-    }
-  })
-}
-
-function getSizeFromSrc(dataURL: string): Promise<number[]> {
-  return new Promise(resolve => {
-    const img = new Image()
-    img.onload = () => resolve([img.width, img.height])
-    img.src = dataURL
-  })
-}

+ 4 - 0
tldraw/packages/react/src/hooks/useKeyboardEvents.ts

@@ -20,6 +20,10 @@ export function useKeyboardEvents() {
       e.preventDefault()
       app.paste(e)
     })
+    document.addEventListener('copy', (e) => {
+      e.preventDefault()
+      app.copy()
+    })
     return () => {
       window.removeEventListener('keydown', onKeyDown)
       window.removeEventListener('keyup', onKeyUp)