Browse Source

Merge pull request #6693 from logseq/feat/whiteboards-create-on-dnd

Peng Xiao 3 years ago
parent
commit
d8d8f39632

+ 0 - 9
src/main/frontend/extensions/tldraw.cljs

@@ -34,14 +34,6 @@
   [props]
   (block/page-cp {:preview? true} {:block/name (gobj/get props "pageName")}))
 
-(defn create-block-shape-by-id
-  [e]
-  (when-let [block (block/get-dragging-block)]
-    (let [uuid (:block/uuid block)
-          client-x (gobj/get e "clientX")
-          client-y (gobj/get e "clientY")]
-      (whiteboard-handler/add-new-block-portal-shape! uuid client-x client-y))))
-
 (defn search-handler
   [q filters]
   (let [{:keys [pages? blocks? files?]} (js->clj filters {:keywordize-keys true})
@@ -101,7 +93,6 @@
         :on-blur (fn [e] 
                    (when (#{"INPUT" "TEXTAREA"} (.-tagName (gobj/get e "target")))
                      (state/clear-edit!)))
-        :on-drop create-block-shape-by-id
         ;; wheel -> overscroll may cause browser navigation
         :on-wheel util/stop-propagation}
 

+ 41 - 37
tldraw/apps/tldraw-logseq/src/app.tsx

@@ -12,7 +12,7 @@ import * as React from 'react'
 import { AppUI } from './components/AppUI'
 import { ContextBar } from './components/ContextBar'
 import { ContextMenu } from './components/ContextMenu'
-import { useFileDrop } from './hooks/useFileDrop'
+import { useDrop } from './hooks/useDrop'
 import { usePaste } from './hooks/usePaste'
 import { useQuickAdd } from './hooks/useQuickAdd'
 import {
@@ -61,27 +61,13 @@ interface LogseqTldrawProps {
   onPersist?: TLReactCallbacks<Shape>['onPersist']
 }
 
-export const App = function App({
+const AppInner = ({
   onPersist,
-  handlers,
-  renderers,
   model,
   ...rest
-}: LogseqTldrawProps): JSX.Element {
-  const memoRenders: any = React.useMemo(() => {
-    return Object.fromEntries(
-      Object.entries(renderers).map(([key, comp]) => {
-        return [key, React.memo(comp)]
-      })
-    )
-  }, [])
-  const contextValue = {
-    renderers: memoRenders,
-    handlers: handlers,
-  }
-
-  const onFileDrop = useFileDrop(contextValue)
-  const onPaste = usePaste(contextValue)
+}: Omit<LogseqTldrawProps, 'renderers' | 'handlers'>) => {
+  const onDrop = useDrop()
+  const onPaste = usePaste()
   const onQuickAdd = useQuickAdd()
   const ref = React.useRef<HTMLDivElement>(null)
 
@@ -94,26 +80,44 @@ export const App = function App({
     [model]
   )
 
+  return (
+    <AppProvider
+      Shapes={shapes}
+      Tools={tools}
+      onDrop={onDrop}
+      onPaste={onPaste}
+      onCanvasDBClick={onQuickAdd}
+      onPersist={onPersistOnDiff}
+      model={model}
+      {...rest}
+    >
+      <ContextMenu collisionRef={ref}>
+        <div ref={ref} className="logseq-tldraw logseq-tldraw-wrapper">
+          <AppCanvas components={components}>
+            <AppUI />
+          </AppCanvas>
+        </div>
+      </ContextMenu>
+    </AppProvider>
+  )
+}
+
+export const App = function App({ renderers, handlers, ...rest }: LogseqTldrawProps): JSX.Element {
+  const memoRenders: any = React.useMemo(() => {
+    return Object.fromEntries(
+      Object.entries(renderers).map(([key, comp]) => {
+        return [key, React.memo(comp)]
+      })
+    )
+  }, [])
+  const contextValue = {
+    renderers: memoRenders,
+    handlers: handlers,
+  }
+
   return (
     <LogseqContext.Provider value={contextValue}>
-      <AppProvider
-        Shapes={shapes}
-        Tools={tools}
-        onFileDrop={onFileDrop}
-        onPaste={onPaste}
-        onCanvasDBClick={onQuickAdd}
-        onPersist={onPersistOnDiff}
-        model={model}
-        {...rest}
-      >
-        <ContextMenu collisionRef={ref}>
-          <div ref={ref} className="logseq-tldraw logseq-tldraw-wrapper">
-            <AppCanvas components={components}>
-              <AppUI />
-            </AppCanvas>
-          </div>
-        </ContextMenu>
-      </AppProvider>
+      <AppInner {...rest} />
     </LogseqContext.Provider>
   )
 }

+ 5 - 1
tldraw/apps/tldraw-logseq/src/components/Devtools/Devtools.tsx

@@ -25,7 +25,11 @@ const HistoryStack = observer(function HistoryStack() {
   }, [])
 
   React.useEffect(() => {
-    anchorRef.current?.querySelector(`[data-item-index="${app.history.pointer}"]`)?.scrollIntoView()
+    requestIdleCallback(() => {
+      anchorRef.current
+        ?.querySelector(`[data-item-index="${app.history.pointer}"]`)
+        ?.scrollIntoView()
+    })
   }, [app.history.pointer])
 
   return anchorRef.current

+ 14 - 0
tldraw/apps/tldraw-logseq/src/hooks/useDrop.ts

@@ -0,0 +1,14 @@
+import type { TLReactCallbacks } from '@tldraw/react'
+import * as React from 'react'
+import type { Shape } from '../lib'
+import { usePaste } from './usePaste'
+
+export function useDrop() {
+  const handlePaste = usePaste()
+  return React.useCallback<TLReactCallbacks<Shape>['onDrop']>(
+    async (app, { dataTransfer, point }) => {
+      handlePaste(app, { point, shiftKey: false, dataTransfer })
+    },
+    []
+  )
+}

+ 0 - 12
tldraw/apps/tldraw-logseq/src/hooks/useFileDrop.ts

@@ -1,12 +0,0 @@
-import type { TLReactCallbacks } from '@tldraw/react'
-import * as React from 'react'
-import type { Shape } from '../lib'
-import type { LogseqContextValue } from '../lib/logseq-context'
-import { usePaste } from './usePaste'
-
-export function useFileDrop(context: LogseqContextValue) {
-  const handlePaste = usePaste(context)
-  return React.useCallback<TLReactCallbacks<Shape>['onFileDrop']>(async (app, { files, point }) => {
-    handlePaste(app, { point, shiftKey: false, files })
-  }, [])
-}

+ 278 - 199
tldraw/apps/tldraw-logseq/src/hooks/usePaste.ts

@@ -1,27 +1,29 @@
 import {
   BoundsUtils,
   getSizeFromSrc,
+  isNonNullable,
   TLAsset,
   TLBinding,
   TLCursor,
   TLShapeModel,
   uniqueId,
   validUUID,
+  createNewLineBinding,
 } from '@tldraw/core'
 import type { TLReactCallbacks } from '@tldraw/react'
 import Vec from '@tldraw/vec'
 import * as React from 'react'
 import { NIL as NIL_UUID } from 'uuid'
 import {
-  type Shape,
   HTMLShape,
-  YouTubeShape,
+  IFrameShape,
+  ImageShape,
   LogseqPortalShape,
   VideoShape,
-  ImageShape,
-  IFrameShape,
+  YouTubeShape,
+  type Shape,
 } from '../lib'
-import type { LogseqContextValue } from '../lib/logseq-context'
+import { LogseqContext } from '../lib/logseq-context'
 
 const isValidURL = (url: string) => {
   try {
@@ -40,121 +42,203 @@ const safeParseJson = (json: string) => {
   }
 }
 
+interface VideoImageAsset extends TLAsset {
+  size?: number[]
+}
+
 const IMAGE_EXTENSIONS = ['.png', '.svg', '.jpg', '.jpeg', '.gif']
 const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg']
 
-// FIXME: for assets, we should prompt the user a loading spinner
-export function usePaste(context: LogseqContextValue) {
-  const { handlers } = context
+function getFileType(filename: string) {
+  // Get extension, verify that it's an image
+  const extensionMatch = filename.match(/\.[0-9a-z]+$/i)
+  if (!extensionMatch) {
+    return 'unknown'
+  }
+  const extension = extensionMatch[0].toLowerCase()
+  if (IMAGE_EXTENSIONS.includes(extension)) {
+    return 'image'
+  }
+  if (VIDEO_EXTENSIONS.includes(extension)) {
+    return 'video'
+  }
+  return 'unknown'
+}
 
-  return React.useCallback<TLReactCallbacks<Shape>['onPaste']>(
-    async (app, { point, shiftKey, files }) => {
-      interface VideoImageAsset extends TLAsset {
-        size: number[]
+type MaybeShapes = Shape['props'][] | null | undefined
+
+type CreateShapeFN<Args extends any[]> = (...args: Args) => Promise<MaybeShapes> | MaybeShapes
+
+/**
+ * Try create a shape from a list of create shape functions. If one of the functions returns a
+ * shape, return it, otherwise try again for the next one until all have been tried.
+ */
+function tryCreateShapeHelper<Args extends any[]>(...fns: CreateShapeFN<Args>[]) {
+  return async (...args: Args) => {
+    for (const fn of fns) {
+      const result = await fn(...(args as any))
+      if (result && result.length > 0) {
+        return result
       }
+    }
+    return null
+  }
+}
+
+// TODO: support file types
+async function getDataFromType(item: DataTransfer | ClipboardItem, type: `text/${string}`) {
+  if (!item.types.includes(type)) {
+    return null
+  }
+  if (item instanceof DataTransfer) {
+    return item.getData(type)
+  }
+  const blob = await item.getType(type)
+  return await blob.text()
+}
 
-      const imageAssetsToCreate: VideoImageAsset[] = []
+// FIXME: for assets, we should prompt the user a loading spinner
+export function usePaste() {
+  const { handlers } = React.useContext(LogseqContext)
+
+  return React.useCallback<TLReactCallbacks<Shape>['onPaste']>(
+    async (app, { point, shiftKey, dataTransfer }) => {
+      let imageAssetsToCreate: VideoImageAsset[] = []
       let assetsToClone: TLAsset[] = []
-      const shapesToCreate: Shape['props'][] = []
       const bindingsToCreate: TLBinding[] = []
 
-      async function createAsset(file: File): Promise<string | null> {
-        return await handlers.saveAsset(file)
-      }
-
-      async function handleAssetUrl(url: string, isVideo: boolean) {
+      async function createAssetsFromURL(url: string, isVideo: boolean): Promise<VideoImageAsset> {
         // Do we already have an asset for this image?
         const existingAsset = Object.values(app.assets).find(asset => asset.src === url)
         if (existingAsset) {
-          imageAssetsToCreate.push(existingAsset as VideoImageAsset)
-          return true
+          return existingAsset as VideoImageAsset
         } else {
-          try {
-            // Create a new asset for this image
-            const asset: VideoImageAsset = {
-              id: uniqueId(),
-              type: isVideo ? 'video' : 'image',
-              src: url,
-              size: await getSizeFromSrc(handlers.makeAssetUrl(url), isVideo),
-            }
-            imageAssetsToCreate.push(asset)
-            return true
-          } catch {
-            return false
+          // Create a new asset for this image
+          const asset: VideoImageAsset = {
+            id: uniqueId(),
+            type: isVideo ? 'video' : 'image',
+            src: url,
+            size: await getSizeFromSrc(handlers.makeAssetUrl(url), isVideo),
           }
+          return asset
         }
       }
 
-      // TODO: handle PDF?
-      async function handleFiles(files: File[]) {
-        let added = false
-        for (const file of files) {
-          // Get extension, verify that it's an image
-          const extensionMatch = file.name.match(/\.[0-9a-z]+$/i)
-          if (!extensionMatch) {
-            continue
-          }
-          const extension = extensionMatch[0].toLowerCase()
-          if (![...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS].includes(extension)) {
-            continue
-          }
-          const isVideo = VIDEO_EXTENSIONS.includes(extension)
-          try {
-            // Turn the image into a base64 dataurl
-            const dataurl = await createAsset(file)
-            if (!dataurl) {
-              continue
+      async function createAssetsFromFiles(files: File[]) {
+        const tasks = files
+          .filter(file => getFileType(file.name) !== 'unknown')
+          .map(async file => {
+            try {
+              const dataurl = await handlers.saveAsset(file)
+              return await createAssetsFromURL(dataurl, getFileType(file.name) === 'video')
+            } catch (err) {
+              console.error(err)
             }
-            if (await handleAssetUrl(dataurl, isVideo)) {
-              added = true
-            }
-          } catch (error) {
-            console.error(error)
-          }
+            return null
+          })
+        return (await Promise.all(tasks)).filter(isNonNullable)
+      }
+
+      function createHTMLShape(text: string) {
+        return {
+          ...HTMLShape.defaultProps,
+          html: text,
+          point: [point[0], point[1]],
         }
-        return added
       }
 
-      async function handleHTML(item: ClipboardItem) {
-        if (item.types.includes('text/html')) {
-          const blob = await item.getType('text/html')
-          const rawText = (await blob.text()).trim()
+      async function tryCreateShapesFromDataTransfer(dataTransfer: DataTransfer) {
+        return tryCreateShapeHelper(
+          tryCreateShapeFromFiles,
+          tryCreateShapeFromTextHTML,
+          tryCreateShapeFromTextPlain,
+          tryCreateShapeFromBlockUUID
+        )(dataTransfer)
+      }
+
+      async function tryCreateShapesFromClipboard() {
+        const items = await navigator.clipboard.read()
+        const createShapesFn = tryCreateShapeHelper(
+          tryCreateShapeFromTextHTML,
+          tryCreateShapeFromTextPlain
+        )
+        const allShapes = (await Promise.all(items.map(item => createShapesFn(item))))
+          .flat()
+          .filter(isNonNullable)
+
+        return allShapes
+      }
+
+      async function tryCreateShapeFromFiles(item: DataTransfer) {
+        const files = Array.from(item.files)
+        if (files.length > 0) {
+          const assets = await createAssetsFromFiles(files)
+          // ? could we get rid of this side effect?
+          imageAssetsToCreate = assets
 
-          shapesToCreate.push({
-            ...HTMLShape.defaultProps,
-            html: rawText,
-            point: [point[0], point[1]],
+          return assets.map((asset, i) => {
+            const defaultProps =
+              asset.type === 'video' ? VideoShape.defaultProps : ImageShape.defaultProps
+            const newShape = {
+              ...defaultProps,
+              // TODO: Should be place near the last edited shape
+              assetId: asset.id,
+              opacity: 1,
+            }
+
+            if (asset.size) {
+              Object.assign(newShape, {
+                point: [
+                  point[0] - asset.size[0] / 4 + i * 16,
+                  point[1] - asset.size[1] / 4 + i * 16,
+                ],
+                size: Vec.div(asset.size, 2),
+              })
+            }
+
+            return newShape
           })
-          return true
         }
-        return false
+        return null
       }
 
-      async function handleTextPlain(item: ClipboardItem) {
-        if (item.types.includes('text/plain')) {
-          const blob = await item.getType('text/plain')
-          const rawText = (await blob.text()).trim()
-
-          if (await handleURL(rawText)) {
-            return true
-          }
+      async function tryCreateShapeFromTextHTML(item: DataTransfer | ClipboardItem) {
+        if (shiftKey) {
+          return null
+        }
+        const rawText = await getDataFromType(item, 'text/html')
+        if (rawText) {
+          return [createHTMLShape(rawText)]
+        }
+        return null
+      }
 
-          if (handleIframe(rawText)) {
-            return true
-          }
+      async function tryCreateShapeFromBlockUUID(dataTransfer: DataTransfer) {
+        // This is a Logseq custom data type defined in frontend.components.block
+        const rawText = dataTransfer.getData('block-uuid')
+        if (rawText) {
+          const text = rawText.trim()
+          return tryCreateShapeHelper(tryCreateLogseqPortalShapesFromString)(`((${text}))`)
+        }
+        return null
+      }
 
-          if (handleTldrawShapes(rawText)) {
-            return true
-          }
-          if (await handleLogseqPortalShapes(rawText)) {
-            return true
-          }
+      async function tryCreateShapeFromTextPlain(item: DataTransfer | ClipboardItem) {
+        const rawText = await getDataFromType(item, 'text/plain')
+        if (rawText) {
+          const text = rawText.trim()
+          return tryCreateShapeHelper(
+            tryCreateShapeFromURL,
+            tryCreateShapeFromIframeString,
+            tryCreateClonedShapesFromJSON,
+            tryCreateLogseqPortalShapesFromString
+          )(text)
         }
 
-        return false
+        return null
       }
 
-      function handleTldrawShapes(rawText: string) {
+      function tryCreateClonedShapesFromJSON(rawText: string) {
         const data = safeParseJson(rawText)
         try {
           if (data?.type === 'logseq/whiteboard-shapes') {
@@ -170,58 +254,58 @@ export function usePaste(context: LogseqContextValue) {
                 maxY: (shape.point?.[1] ?? point[1]) + (shape.size?.[1] ?? 4),
               }))
             )
-            const clonedShapes = shapes.map(shape => {
+            const shapesToCreate = shapes.map(shape => {
               return {
                 ...shape,
+                id: uniqueId(),
                 point: [
                   point[0] + shape.point![0] - commonBounds.minX,
                   point[1] + shape.point![1] - commonBounds.minY,
                 ],
               }
             })
-            // @ts-expect-error - This is a valid shape
-            shapesToCreate.push(...clonedShapes)
 
             // Try to rebinding the shapes to the new assets
-            shapesToCreate.forEach((s, idx) => {
-              if (s.handles) {
-                Object.values(s.handles).forEach(h => {
-                  if (h.bindingId) {
-                    // try to bind the new shape
-                    const binding = app.currentPage.bindings[h.bindingId]
-                    // FIXME: if copy from a different whiteboard, the binding info
-                    // will not be available
-                    if (binding) {
-                      // if the copied binding from/to is in the source
-                      const oldFromIdx = shapes.findIndex(s => s.id === binding.fromId)
-                      const oldToIdx = shapes.findIndex(s => s.id === binding.toId)
-                      if (binding && oldFromIdx !== -1 && oldToIdx !== -1) {
-                        const newBinding: TLBinding = {
-                          ...binding,
-                          id: uniqueId(),
-                          fromId: shapesToCreate[oldFromIdx].id,
-                          toId: shapesToCreate[oldToIdx].id,
-                        }
-                        bindingsToCreate.push(newBinding)
-                        h.bindingId = newBinding.id
-                      } else {
-                        h.bindingId = undefined
-                      }
+            shapesToCreate
+              .flatMap(s => Object.values(s.handles ?? {}))
+              .forEach(h => {
+                if (!h.bindingId) {
+                  return
+                }
+                // try to bind the new shape
+                const binding = app.currentPage.bindings[h.bindingId]
+                // FIXME: if copy from a different whiteboard, the binding info
+                // will not be available
+                if (binding) {
+                  // if the copied binding from/to is in the source
+                  const oldFromIdx = shapes.findIndex(s => s.id === binding.fromId)
+                  const oldToIdx = shapes.findIndex(s => s.id === binding.toId)
+                  if (binding && oldFromIdx !== -1 && oldToIdx !== -1) {
+                    const newBinding: TLBinding = {
+                      ...binding,
+                      id: uniqueId(),
+                      fromId: shapesToCreate[oldFromIdx].id,
+                      toId: shapesToCreate[oldToIdx].id,
                     }
+                    bindingsToCreate.push(newBinding)
+                    h.bindingId = newBinding.id
+                  } else {
+                    h.bindingId = undefined
                   }
-                })
-              }
-            })
+                } else {
+                  console.warn('binding not found', h.bindingId)
+                }
+              })
 
-            return true
+            return shapesToCreate as Shape['props'][]
           }
         } catch (err) {
           console.error(err)
         }
-        return false
+        return null
       }
 
-      async function handleURL(rawText: string) {
+      async function tryCreateShapeFromURL(rawText: string) {
         if (isValidURL(rawText)) {
           const isYoutubeUrl = (url: string) => {
             const youtubeRegex =
@@ -229,120 +313,108 @@ export function usePaste(context: LogseqContextValue) {
             return youtubeRegex.test(url)
           }
           if (isYoutubeUrl(rawText)) {
-            shapesToCreate.push({
-              ...YouTubeShape.defaultProps,
-              url: rawText,
-              point: [point[0], point[1]],
-            })
-            return true
-          }
-          const extension = rawText.match(/\.[0-9a-z]+$/i)?.[0].toLowerCase()
-          if (
-            extension &&
-            [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS].includes(extension) &&
-            (await handleAssetUrl(rawText, VIDEO_EXTENSIONS.includes(extension)))
-          ) {
-            return true
+            return [
+              {
+                ...YouTubeShape.defaultProps,
+                url: rawText,
+                point: [point[0], point[1]],
+              },
+            ]
           }
 
-          shapesToCreate.push({
-            ...IFrameShape.defaultProps,
-            url: rawText,
-            point: [point[0], point[1]],
-          })
-          return true
+          return [
+            {
+              ...IFrameShape.defaultProps,
+              url: rawText,
+              point: [point[0], point[1]],
+            },
+          ]
         }
-        return false
+        return null
       }
 
-      function handleIframe(rawText: string) {
+      function tryCreateShapeFromIframeString(rawText: string) {
         // if rawText is iframe text
         if (rawText.startsWith('<iframe')) {
-          shapesToCreate.push({
-            ...HTMLShape.defaultProps,
-            html: rawText,
-            point: [point[0], point[1]],
-          })
-          return true
+          return [
+            {
+              ...HTMLShape.defaultProps,
+              html: rawText,
+              point: [point[0], point[1]],
+            },
+          ]
         }
-        return false
+        return null
       }
 
-      async function handleLogseqPortalShapes(rawText: string) {
+      async function tryCreateLogseqPortalShapesFromString(rawText: string) {
         if (/^\(\(.*\)\)$/.test(rawText) && rawText.length === NIL_UUID.length + 4) {
           const blockRef = rawText.slice(2, -2)
           if (validUUID(blockRef)) {
-            shapesToCreate.push({
+            return [
+              {
+                ...LogseqPortalShape.defaultProps,
+                point: [point[0], point[1]],
+                size: [400, 0], // use 0 here to enable auto-resize
+                pageId: blockRef,
+                blockType: 'B' as 'B',
+              },
+            ]
+          }
+        }
+        // [[page name]] ?
+        else if (/^\[\[.*\]\]$/.test(rawText)) {
+          const pageName = rawText.slice(2, -2)
+          return [
+            {
               ...LogseqPortalShape.defaultProps,
               point: [point[0], point[1]],
               size: [400, 0], // use 0 here to enable auto-resize
-              pageId: blockRef,
-              blockType: 'B',
-            })
-            return true
-          }
-        } else if (/^\[\[.*\]\]$/.test(rawText)) {
-          const pageName = rawText.slice(2, -2)
-          shapesToCreate.push({
-            ...LogseqPortalShape.defaultProps,
-            point: [point[0], point[1]],
-            size: [400, 0], // use 0 here to enable auto-resize
-            pageId: pageName,
-            blockType: 'P',
-          })
-          return true
+              pageId: pageName,
+              blockType: 'P' as 'P',
+            },
+          ]
         }
 
+        // Otherwise, creating a new block that belongs to the current whiteboard
         const uuid = handlers?.addNewBlock(rawText)
         if (uuid) {
           // create text shape
-          shapesToCreate.push({
-            ...LogseqPortalShape.defaultProps,
-            id: uniqueId(),
-            size: [400, 0], // use 0 here to enable auto-resize
-            point: [point[0], point[1]],
-            pageId: uuid,
-            blockType: 'B',
-            compact: true,
-          })
-          return true
+          return [
+            {
+              ...LogseqPortalShape.defaultProps,
+              size: [400, 0], // use 0 here to enable auto-resize
+              point: [point[0], point[1]],
+              pageId: uuid,
+              blockType: 'B' as 'B',
+              compact: true,
+            },
+          ]
         }
-        return false
+
+        return null
       }
 
       app.cursors.setCursor(TLCursor.Progress)
 
+      let newShapes: Shape['props'][] = []
       try {
-        if (files && files.length > 0) {
-          await handleFiles(files)
+        if (dataTransfer) {
+          newShapes.push(...((await tryCreateShapesFromDataTransfer(dataTransfer)) ?? []))
         } else {
-          for (const item of await navigator.clipboard.read()) {
-            let handled = !shiftKey ? await handleHTML(item) : false
-            if (!handled) {
-              await handleTextPlain(item)
-            }
-          }
+          // from Clipboard app or Shift copy etc
+          // in this case, we do not have the dataTransfer object
+          newShapes.push(...((await tryCreateShapesFromClipboard()) ?? []))
         }
       } catch (error) {
         console.error(error)
       }
 
-      const allShapesToAdd: TLShapeModel[] = [
-        // assets to images
-        ...imageAssetsToCreate.map((asset, i) => ({
-          ...(asset.type === 'video' ? VideoShape : ImageShape).defaultProps,
-          // TODO: Should be place near the last edited shape
-          point: [point[0] - asset.size[0] / 4 + i * 16, point[1] - asset.size[1] / 4 + i * 16],
-          size: Vec.div(asset.size, 2),
-          assetId: asset.id,
-          opacity: 1,
-        })),
-        ...shapesToCreate,
-      ].map(shape => {
+      const allShapesToAdd: TLShapeModel[] = newShapes.map(shape => {
         return {
           ...shape,
           parentId: app.currentPageId,
-          id: uniqueId(),
+          id: validUUID(shape.id) ? shape.id : uniqueId(),
         }
       })
 
@@ -354,6 +426,13 @@ export function usePaste(context: LogseqContextValue) {
         if (allShapesToAdd.length > 0) {
           app.createShapes(allShapesToAdd)
         }
+
+        if (app.selectedShapesArray.length === 1 && allShapesToAdd.length === 1) {
+          const source = app.selectedShapesArray[0]
+          const target = app.getShapeById(allShapesToAdd[0].id!)!
+          app.createNewLineBinding(source, target)
+        }
+
         app.currentPage.updateBindings(Object.fromEntries(bindingsToCreate.map(b => [b.id, b])))
         app.setSelectedShapes(allShapesToAdd.map(s => s.id))
       })

+ 1 - 1
tldraw/apps/tldraw-logseq/src/lib/logseq-context.ts

@@ -36,4 +36,4 @@ export interface LogseqContextValue {
   }
 }
 
-export const LogseqContext = React.createContext<Partial<LogseqContextValue>>({})
+export const LogseqContext = React.createContext<LogseqContextValue>({} as LogseqContextValue)

+ 21 - 5
tldraw/apps/tldraw-logseq/src/lib/shapes/BindingIndicator.tsx

@@ -1,16 +1,32 @@
 interface BindingIndicatorProps {
   strokeWidth: number
   size: number[]
+  mode: 'svg' | 'html'
 }
-export function BindingIndicator({ strokeWidth, size }: BindingIndicatorProps) {
-  return (
+export function BindingIndicator({ strokeWidth, size, mode }: BindingIndicatorProps) {
+  return mode === 'svg' ? (
     <rect
       className="tl-binding-indicator"
       x={strokeWidth}
       y={strokeWidth}
-      width={Math.max(0, size[0] - strokeWidth / 2)}
-      height={Math.max(0, size[1] - strokeWidth / 2)}
-      strokeWidth={16 * 2}
+      rx={2}
+      ry={2}
+      width={Math.max(0, size[0] - strokeWidth * 2)}
+      height={Math.max(0, size[1] - strokeWidth * 2)}
+      strokeWidth={strokeWidth * 4}
+    />
+  ) : (
+    <div
+      className="tl-binding-indicator"
+      style={{
+        position: 'absolute',
+        left: 0,
+        top: 0,
+        right: 0,
+        bottom: 0,
+        boxShadow: '0 0 0 4px var(--tl-binding)',
+        borderRadius: 4,
+      }}
     />
   )
 }

+ 1 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/BoxShape.tsx

@@ -44,7 +44,7 @@ export class BoxShape extends TLBoxShape<BoxShapeProps> {
 
     return (
       <SVGContainer {...events} opacity={isErasing ? 0.2 : opacity}>
-        {isBinding && <BindingIndicator strokeWidth={strokeWidth} size={[w, h]} />}
+        {isBinding && <BindingIndicator mode="svg" strokeWidth={strokeWidth} size={[w, h]} />}
         <rect
           className={isSelected || !noFill ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}
           x={strokeWidth / 2}

+ 4 - 1
tldraw/apps/tldraw-logseq/src/lib/shapes/ImageShape.tsx

@@ -4,6 +4,7 @@ import { HTMLContainer, TLComponentProps } from '@tldraw/react'
 import { TLAsset, TLImageShape, TLImageShapeProps } from '@tldraw/core'
 import { observer } from 'mobx-react-lite'
 import { LogseqContext } from '../logseq-context'
+import { BindingIndicator } from './BindingIndicator'
 
 export interface ImageShapeProps extends TLImageShapeProps {
   type: 'image'
@@ -27,7 +28,7 @@ export class ImageShape extends TLImageShape<ImageShapeProps> {
     isAspectRatioLocked: true,
   }
 
-  ReactComponent = observer(({ events, isErasing, asset }: TLComponentProps) => {
+  ReactComponent = observer(({ events, isErasing, isBinding, asset }: TLComponentProps) => {
     const {
       props: {
         opacity,
@@ -45,6 +46,8 @@ export class ImageShape extends TLImageShape<ImageShapeProps> {
 
     return (
       <HTMLContainer {...events} opacity={isErasing ? 0.2 : opacity}>
+        {isBinding && <BindingIndicator mode="html" strokeWidth={4} size={[w, h]} />}
+
         <div style={{ width: '100%', height: '100%', overflow: 'hidden' }}>
           {asset && (
             <img

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

@@ -8,6 +8,7 @@ import {
   validUUID,
 } from '@tldraw/core'
 import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
+import { useDebouncedValue } from '@tldraw/react'
 import Vec from '@tldraw/vec'
 import { action, computed, makeObservable } from 'mobx'
 import { observer } from 'mobx-react-lite'
@@ -17,6 +18,7 @@ import { TablerIcon } from '../../components/icons'
 import { TextInput } from '../../components/inputs/TextInput'
 import { useCameraMovingRef } from '../../hooks/useCameraMoving'
 import { LogseqContext, type SearchResult } from '../logseq-context'
+import { BindingIndicator } from './BindingIndicator'
 import { CustomStyleProps, withClampedStyles } from './style-props'
 
 const HEADER_HEIGHT = 40
@@ -102,18 +104,18 @@ const highlightedJSX = (input: string, keyword: string) => {
 const useSearch = (q: string, searchFilter: 'B' | 'P' | null) => {
   const { handlers } = React.useContext(LogseqContext)
   const [results, setResults] = React.useState<SearchResult | null>(null)
+  const dq = useDebouncedValue(q, 200)
 
   React.useEffect(() => {
     let canceled = false
-    const searchHandler = handlers?.search
-    if (q.length > 0 && searchHandler) {
+    if (dq.length > 0) {
       const filter = { 'pages?': true, 'blocks?': true, 'files?': false }
       if (searchFilter === 'B') {
         filter['pages?'] = false
       } else if (searchFilter === 'P') {
         filter['blocks?'] = false
       }
-      handlers.search(q, filter).then(_results => {
+      handlers.search(dq, filter).then(_results => {
         if (!canceled) {
           setResults(_results)
         }
@@ -124,7 +126,7 @@ const useSearch = (q: string, searchFilter: 'B' | 'P' | null) => {
     return () => {
       canceled = true
     }
-  }, [q, handlers?.search])
+  }, [dq, handlers?.search])
 
   return results
 }
@@ -653,7 +655,7 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
   ReactComponent = observer((componentProps: TLComponentProps) => {
     const { events, isErasing, isEditing, isBinding } = componentProps
     const {
-      props: { opacity, pageId, stroke, fill, scaleLevel },
+      props: { opacity, pageId, stroke, fill, scaleLevel, strokeWidth, size },
     } = this
 
     const app = useApp<Shape>()
@@ -747,6 +749,7 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
         }}
         {...events}
       >
+        {isBinding && <BindingIndicator mode="html" strokeWidth={strokeWidth} size={size} />}
         <div
           onWheelCapture={stop}
           onPointerDown={stop}
@@ -767,9 +770,6 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
               data-portal-selected={portalSelected}
               style={{
                 background: this.props.compact ? 'transparent' : fill,
-                boxShadow: isBinding
-                  ? '0px 0px 0 var(--tl-binding-distance) var(--tl-binding)'
-                  : 'none',
                 color: stroke,
                 width: `calc(100% / ${scaleRatio})`,
                 height: `calc(100% / ${scaleRatio})`,

+ 2 - 2
tldraw/apps/tldraw-logseq/src/styles.css

@@ -829,12 +829,12 @@ html[data-theme='dark'] {
   fill: none;
   stroke: transparent;
   pointer-events: stroke;
-  stroke-width: min(100px, calc(24px * var(--tl-scale)));
+  stroke-width: min(100px, calc(12px * var(--tl-scale)));
 }
 
 .tl-hitarea-fill {
   fill: transparent;
   stroke: transparent;
   pointer-events: all;
-  stroke-width: min(100px, calc(24px * var(--tl-scale)));
+  stroke-width: min(100px, calc(12px * var(--tl-scale)));
 }

+ 4 - 0
tldraw/packages/core/src/lib/TLApi/TLApi.ts

@@ -191,4 +191,8 @@ export class TLApi<S extends TLShape = TLShape, K extends TLEventMap = TLEventMa
     this.app.redo()
     return this
   }
+
+  createNewLineBinding = (source: TLShape, target: TLShape) => {
+    return this.app.createNewLineBinding(source, target)
+  }
 }

+ 34 - 8
tldraw/packages/core/src/lib/TLApp/TLApp.ts

@@ -15,8 +15,9 @@ import type {
   TLSubscriptionEventInfo,
   TLStateEvents,
   TLEvents,
+  TLHandle,
 } from '../../types'
-import { KeyUtils, BoundsUtils } from '../../utils'
+import { KeyUtils, BoundsUtils, isNonNullable, createNewLineBinding } from '../../utils'
 import type { TLShape, TLShapeConstructor, TLShapeModel } from '../shapes'
 import { TLApi } from '../TLApi'
 import { TLCursors } from '../TLCursors'
@@ -436,11 +437,10 @@ export class TLApp<
 
   paste = (e?: ClipboardEvent, shiftKey?: boolean) => {
     if (!this.editingShape) {
-      const fileList = e?.clipboardData?.files
       this.notify('paste', {
         point: this.inputs.currentPoint,
         shiftKey: !!shiftKey,
-        files: fileList ? Array.from(fileList) : undefined,
+        dataTransfer: e?.clipboardData ?? undefined,
       })
     }
   }
@@ -450,9 +450,9 @@ export class TLApp<
     this.api.deleteShapes()
   }
 
-  dropFiles = (files: FileList, point?: number[]) => {
-    this.notify('drop-files', {
-      files: Array.from(files),
+  drop = (dataTransfer: DataTransfer, point?: number[]) => {
+    this.notify('drop', {
+      dataTransfer,
       point: point
         ? this.viewport.getPagePoint(point)
         : BoundsUtils.getBoundsCenter(this.viewport.currentView),
@@ -577,9 +577,21 @@ export class TLApp<
   @observable bindingIds?: string[]
 
   @computed get bindingShapes(): S[] | undefined {
-    const { bindingIds, currentPage } = this
+    const activeBindings =
+      this.selectedShapesArray.length === 1
+        ? this.selectedShapesArray
+            .flatMap(s => Object.values(s.props.handles ?? {}))
+            .flatMap(h => h.bindingId)
+            .filter(isNonNullable)
+            .flatMap(binding => [
+              this.currentPage.bindings[binding]?.fromId,
+              this.currentPage.bindings[binding]?.toId,
+            ])
+            .filter(isNonNullable)
+        : []
+    const bindingIds = [...(this.bindingIds ?? []), ...activeBindings]
     return bindingIds
-      ? currentPage.shapes.filter(shape => bindingIds?.includes(shape.id))
+      ? this.currentPage.shapes.filter(shape => bindingIds?.includes(shape.id))
       : undefined
   }
 
@@ -592,6 +604,20 @@ export class TLApp<
     return this.setBindingShapes()
   }
 
+  @action createNewLineBinding = (source: TLShape, target: TLShape) => {
+    if (source.canBind && target.canBind) {
+      const result = createNewLineBinding(source, target)
+      if (result) {
+        const [newLine, newBindings] = result
+        this.createShapes([newLine])
+        this.currentPage.updateBindings(Object.fromEntries(newBindings.map(b => [b.id, b])))
+        this.persist()
+        return true
+      }
+    }
+    return false
+  }
+
   /* ---------------------- Brush --------------------- */
 
   @observable brush?: TLBounds

+ 3 - 33
tldraw/packages/core/src/lib/TLBaseLineBindingState.ts

@@ -2,6 +2,7 @@ import Vec from '@tldraw/vec'
 import { transaction } from 'mobx'
 import type { TLBinding, TLEventMap, TLHandle, TLStateEvents } from '../types'
 import { deepMerge, GeomUtils } from '../utils'
+import { findBindingPoint } from '../utils/BindingUtils'
 import type { TLLineShape, TLLineShapeProps, TLShape } from './shapes'
 import type { TLApp } from './TLApp'
 import type { TLTool } from './TLTool'
@@ -109,7 +110,7 @@ export class TLBaseLineBindingState<
 
         // Don't bind the start handle if both handles are inside of the target shape.
         if (!modKey && !startTarget.hitTestPoint(Vec.add(next.shape.point, endHandle.point))) {
-          nextStartBinding = this.findBindingPoint(
+          nextStartBinding = findBindingPoint(
             shape.props,
             startTarget,
             'start',
@@ -151,7 +152,7 @@ export class TLBaseLineBindingState<
         })
 
       for (const target of targets) {
-        draggedBinding = this.findBindingPoint(
+        draggedBinding = findBindingPoint(
           shape.props,
           target,
           this.handleId,
@@ -240,35 +241,4 @@ export class TLBaseLineBindingState<
       }
     }
   }
-
-  private findBindingPoint = (
-    shape: TLLineShapeProps,
-    target: TLShape,
-    handleId: 'start' | 'end',
-    bindingId: string,
-    point: number[],
-    origin: number[],
-    direction: number[],
-    bindAnywhere: boolean
-  ) => {
-    const bindingPoint = target.getBindingPoint(
-      point, // fix dead center bug
-      origin,
-      direction,
-      bindAnywhere
-    )
-
-    // Not all shapes will produce a binding point
-    if (!bindingPoint) return
-
-    return {
-      id: bindingId,
-      type: 'line',
-      fromId: shape.id,
-      toId: target.id,
-      handleId: handleId,
-      point: Vec.toFixed(bindingPoint.point),
-      distance: bindingPoint.distance,
-    }
-  }
 }

+ 3 - 3
tldraw/packages/core/src/types/types.ts

@@ -153,12 +153,12 @@ export type TLSubscriptionEvent =
       info: TLShape[]
     }
   | {
-      event: 'drop-files'
-      info: { files: File[]; point: number[] }
+      event: 'drop'
+      info: { dataTransfer: DataTransfer; point: number[] }
     }
   | {
       event: 'paste'
-      info: { point: number[]; shiftKey: boolean; files?: File[] }
+      info: { point: number[]; shiftKey: boolean; dataTransfer?: DataTransfer }
     }
   | {
       event: 'create-assets'

+ 85 - 0
tldraw/packages/core/src/utils/BindingUtils.ts

@@ -0,0 +1,85 @@
+import Vec from '@tldraw/vec'
+import { uniqueId } from '.'
+import { TLLineShape, TLLineShapeProps, TLShape } from '../lib'
+import type { TLBinding } from '../types'
+
+export function findBindingPoint(
+  shape: TLLineShapeProps,
+  target: TLShape,
+  handleId: 'start' | 'end',
+  bindingId: string,
+  point: number[],
+  origin: number[],
+  direction: number[],
+  bindAnywhere: boolean
+) {
+  const bindingPoint = target.getBindingPoint(
+    point, // fix dead center bug
+    origin,
+    direction,
+    bindAnywhere
+  )
+
+  // Not all shapes will produce a binding point
+  if (!bindingPoint) return
+
+  return {
+    id: bindingId,
+    type: 'line',
+    fromId: shape.id,
+    toId: target.id,
+    handleId: handleId,
+    point: Vec.toFixed(bindingPoint.point),
+    distance: bindingPoint.distance,
+  }
+}
+
+/** Given source & target, calculate a new Line shape from the center of source and to the center of target */
+export function createNewLineBinding(
+  source: TLShape,
+  target: TLShape
+): [TLLineShapeProps, TLBinding[]] | null {
+  // cs -> center of source, etc
+  const cs = source.getCenter()
+  const ct = target.getCenter()
+  const lineId = uniqueId()
+  const lineShape = {
+    ...TLLineShape.defaultProps,
+    id: lineId,
+    type: TLLineShape.id,
+    parentId: source.props.parentId,
+    point: cs,
+  }
+
+  const startBinding = findBindingPoint(
+    lineShape,
+    source,
+    'start',
+    uniqueId(),
+    cs,
+    cs,
+    Vec.uni(Vec.sub(ct, cs)),
+    false
+  )
+
+  const endBinding = findBindingPoint(
+    lineShape,
+    target,
+    'end',
+    uniqueId(),
+    ct,
+    ct,
+    Vec.uni(Vec.sub(cs, ct)),
+    false
+  )
+
+  if (startBinding && endBinding) {
+    lineShape.handles.start.point = [0, 0]
+    lineShape.handles.end.point = Vec.sub(ct, cs)
+    lineShape.handles.start.bindingId = startBinding.id
+    lineShape.handles.end.bindingId = endBinding.id
+
+    return [lineShape, [startBinding, endBinding]]
+  }
+  return null
+}

+ 1 - 0
tldraw/packages/core/src/utils/index.ts

@@ -5,6 +5,7 @@ export * from './KeyUtils'
 export * from './GeomUtils'
 export * from './PolygonUtils'
 export * from './SvgPathUtils'
+export * from './BindingUtils'
 export * from './DataUtils'
 export * from './TextUtils'
 export * from './getTextSize'

+ 1 - 0
tldraw/packages/react/src/hooks/index.ts

@@ -15,3 +15,4 @@ export * from './useHandleEvents'
 export * from './useCursor'
 export * from './useZoom'
 export * from './useMinimapEvents'
+export * from './useDebounced'

+ 1 - 2
tldraw/packages/react/src/hooks/useCanvasEvents.ts

@@ -53,9 +53,8 @@ export function useCanvasEvents() {
     const onDrop = async (e: React.DragEvent<Element>) => {
       e.preventDefault()
 
-      if (!e.dataTransfer.files?.length) return
       const point = [e.clientX, e.clientY]
-      app.dropFiles(e.dataTransfer.files, point)
+      app.drop(e.dataTransfer, point)
     }
 
     const onDragOver = (e: React.DragEvent<Element>) => {

+ 2 - 2
tldraw/packages/react/src/hooks/useSetup.ts

@@ -18,7 +18,7 @@ export function useSetup<
     onCreateShapes,
     onDeleteAssets,
     onDeleteShapes,
-    onFileDrop,
+    onDrop,
     onPaste,
     onCanvasDBClick,
   } = props
@@ -45,7 +45,7 @@ export function useSetup<
     if (onCreateAssets) unsubs.push(app.subscribe('create-assets', onCreateAssets))
     if (onDeleteShapes) unsubs.push(app.subscribe('delete-shapes', onDeleteShapes))
     if (onDeleteAssets) unsubs.push(app.subscribe('delete-assets', onDeleteAssets))
-    if (onFileDrop) unsubs.push(app.subscribe('drop-files', onFileDrop))
+    if (onDrop) unsubs.push(app.subscribe('drop', onDrop))
     if (onPaste) unsubs.push(app.subscribe('paste', onPaste))
     if (onCanvasDBClick) unsubs.push(app.subscribe('canvas-dbclick', onCanvasDBClick))
     // Kind of unusual, is this the right pattern?

+ 1 - 2
tldraw/packages/react/src/index.ts

@@ -1,8 +1,7 @@
 import type { TLOffset } from '@tldraw/core'
 export * from './types'
 export * from './lib'
-export * from './hooks/useApp'
-export * from './hooks/useRendererContext'
+export * from './hooks'
 export * from './components/HTMLContainer'
 export * from './components/SVGContainer'
 export * from './components/App'

+ 1 - 1
tldraw/packages/react/src/types/TLReactSubscriptions.tsx

@@ -38,7 +38,7 @@ export interface TLReactCallbacks<
   onCreateAssets: TLReactCallback<S, R, 'create-assets'>
   onDeleteShapes: TLReactCallback<S, R, 'delete-shapes'>
   onDeleteAssets: TLReactCallback<S, R, 'delete-assets'>
-  onFileDrop: TLReactCallback<S, R, 'drop-files'>
+  onDrop: TLReactCallback<S, R, 'drop'>
   onCanvasDBClick: TLReactCallback<S, R, 'canvas-dbclick'>
   onPaste: TLReactCallback<S, R, 'paste'>
 }