Browse Source

Merge branch 'whiteboards' into feat/whiteboards-create-button

Konstantinos Kaloutas 3 years ago
parent
commit
3e74e715a1
49 changed files with 1343 additions and 834 deletions
  1. 2 2
      .github/workflows/e2e.yml
  2. 12 2
      src/electron/electron/utils.js
  3. 0 4
      src/main/frontend/components/block.cljs
  4. 4 1
      src/main/frontend/components/page.cljs
  5. 1 2
      src/main/frontend/components/settings.cljs
  6. 78 44
      src/main/frontend/components/whiteboard.cljs
  7. 32 3
      src/main/frontend/components/whiteboard.css
  8. 0 9
      src/main/frontend/extensions/tldraw.cljs
  9. 4 0
      src/main/frontend/handler/route.cljs
  10. 18 22
      src/main/frontend/handler/whiteboard.cljs
  11. 1 1
      src/main/frontend/state.cljs
  12. 35 17
      src/main/frontend/ui.cljs
  13. 31 0
      src/main/frontend/util.cljc
  14. 3 3
      tldraw/apps/tldraw-logseq/package.json
  15. 41 37
      tldraw/apps/tldraw-logseq/src/app.tsx
  16. 5 1
      tldraw/apps/tldraw-logseq/src/components/Devtools/Devtools.tsx
  17. 14 0
      tldraw/apps/tldraw-logseq/src/hooks/useDrop.ts
  18. 0 12
      tldraw/apps/tldraw-logseq/src/hooks/useFileDrop.ts
  19. 292 199
      tldraw/apps/tldraw-logseq/src/hooks/usePaste.ts
  20. 1 0
      tldraw/apps/tldraw-logseq/src/index.ts
  21. 1 1
      tldraw/apps/tldraw-logseq/src/lib/logseq-context.ts
  22. 21 5
      tldraw/apps/tldraw-logseq/src/lib/shapes/BindingIndicator.tsx
  23. 1 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/BoxShape.tsx
  24. 4 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/ImageShape.tsx
  25. 99 42
      tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx
  26. 1 0
      tldraw/apps/tldraw-logseq/src/lib/tools/LogseqPortalTool/states/CreatingState.tsx
  27. 59 10
      tldraw/apps/tldraw-logseq/src/styles.css
  28. 2 2
      tldraw/demo/package.json
  29. 2 2
      tldraw/package.json
  30. 4 3
      tldraw/packages/core/package.json
  31. 4 0
      tldraw/packages/core/src/lib/TLApi/TLApi.ts
  32. 34 8
      tldraw/packages/core/src/lib/TLApp/TLApp.ts
  33. 3 33
      tldraw/packages/core/src/lib/TLBaseLineBindingState.ts
  34. 2 0
      tldraw/packages/core/src/lib/TLPage/TLPage.ts
  35. 2 0
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/IdleState.ts
  36. 4 2
      tldraw/packages/core/src/lib/tools/TLSelectTool/states/PointingSelectedShapeState.ts
  37. 3 3
      tldraw/packages/core/src/types/types.ts
  38. 85 0
      tldraw/packages/core/src/utils/BindingUtils.ts
  39. 30 0
      tldraw/packages/core/src/utils/cache.ts
  40. 2 0
      tldraw/packages/core/src/utils/index.ts
  41. 3 3
      tldraw/packages/react/package.json
  42. 2 0
      tldraw/packages/react/src/components/ui/SelectionBackground/SelectionBackground.tsx
  43. 1 5
      tldraw/packages/react/src/components/ui/SelectionForeground/SelectionForeground.tsx
  44. 1 0
      tldraw/packages/react/src/hooks/index.ts
  45. 1 2
      tldraw/packages/react/src/hooks/useCanvasEvents.ts
  46. 2 2
      tldraw/packages/react/src/hooks/useSetup.ts
  47. 1 2
      tldraw/packages/react/src/index.ts
  48. 1 1
      tldraw/packages/react/src/types/TLReactSubscriptions.tsx
  49. 394 347
      tldraw/yarn.lock

+ 2 - 2
.github/workflows/e2e.yml

@@ -80,10 +80,10 @@ jobs:
         env:
           PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true
 
-      # NOTE: require the app to be build in debug mode
+      # NOTE: require the app to be build with DEV-RELEASE flag
       - name: Prepare E2E test build
         run: |
-          yarn gulp:build && clojure -M:cljs release app electron --debug
+          yarn gulp:build && clojure -M:cljs release app electron --config-merge "{:closure-defines {frontend.config/DEV-RELEASE true}}" --debug
 
       # NOTE: should include .shadow-cljs if in dev mode(compile)
       - name: Create Archive for build

+ 12 - 2
src/electron/electron/utils.js

@@ -5,21 +5,31 @@ import fs from 'fs';
 // We set an intercept on incoming requests to disable x-frame-options
 // headers.
 
+// Should we do this? Does this make evil sites doing danagerous things?
 export const disableXFrameOptions = (win) => {
-  win.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] },
+  win.webContents.session.webRequest.onHeadersReceived(
     (d, c) => {
       if (d.responseHeaders['X-Frame-Options']) {
         delete d.responseHeaders['X-Frame-Options']
       } else if (d.responseHeaders['x-frame-options']) {
         delete d.responseHeaders['x-frame-options']
       }
+      
+      if (d.responseHeaders['Content-Security-Policy']) {
+        delete d.responseHeaders['Content-Security-Policy']
+      }
+
+      if (d.responseHeaders['content-security-policy']) {
+        delete d.responseHeaders['content-security-policy']
+      }
+
 
       c({ cancel: false, responseHeaders: d.responseHeaders })
     }
   )
 }
 
-export async function getAllFiles (dir, exts) {
+export async function getAllFiles(dir, exts) {
   const dirents = await readdir(dir, { withFileTypes: true })
 
   if (exts) {

+ 0 - 4
src/main/frontend/components/block.cljs

@@ -103,10 +103,6 @@
 ;; TODO:
 ;; add `key`
 
-(defn get-dragging-block
-  []
-  @*dragging-block)
-
 (defn- remove-nils
   [col]
   (remove nil? col))

+ 4 - 1
src/main/frontend/components/page.cljs

@@ -309,12 +309,15 @@
                               :old-name old-name
                               :untitled? untitled?
                               :whiteboard-page? whiteboard-page?}))
-        [:span.title.inline-block
+        [:span.title.block
          {:data-value (rum/react *input-value)
           :data-ref page-name
           :style {:opacity (when @*edit? 0)
                   :pointer-events "none"
                   :font-weight "inherit"
+                  :white-space "nowrap"
+                  :overflow "hidden"
+                  :text-overflow "ellipsis"
                   :min-width "80px"}}
          (cond @*edit? [:span {:style {:white-space "pre"}} (rum/react *input-value)]
                untitled? [:span.opacity-50 (t :untitled)]

+ 1 - 2
src/main/frontend/components/settings.cljs

@@ -28,8 +28,7 @@
             [reitit.frontend.easy :as rfe]
             [rum.core :as rum]
             [frontend.mobile.util :as mobile-util]
-            [frontend.db :as db]
-            [frontend.handler.user :as user]))
+            [frontend.db :as db]))
 
 (defn toggle
   [label-for name state on-toggle & [detail-text]]

+ 78 - 44
src/main/frontend/components/whiteboard.cljs

@@ -41,6 +41,27 @@
     (when generate-preview
       (generate-preview tldr))))
 
+(rum/defc dropdown
+  [label children show? outside-click-hander]
+  (let [anchor-ref (rum/use-ref nil)
+        content-ref (rum/use-ref nil)
+        rect (util/use-component-size (when show? anchor-ref))
+        _ (util/use-click-outside content-ref outside-click-hander)
+        [d-open set-d-open] (rum/use-state false)
+        _ (rum/use-effect! (fn [] (js/setTimeout #(set-d-open show?) 100))
+                           [show?])]
+    [:div.dropdown-anchor {:ref anchor-ref}
+     label
+     (when (and rect show? (> (.-y rect) 0))
+       (ui/portal
+        [:div.fixed.shadow-lg.color-level.px-2.rounded-lg.transition
+         {:ref content-ref
+          :style {:opacity (if d-open 1 0)
+                  :width "240px"
+                  :min-height "40px"
+                  :left (+ (* 0.5 (.-width rect)) (.-x rect) -120)
+                  :top (+ (.-y rect) (.-height rect) 8)}} children]))]))
+
 (rum/defc page-refs-count < rum/static
   ([page-name classname]
    (page-refs-count page-name classname nil))
@@ -62,24 +83,19 @@
         #(.removeEventListener js/document.body "mousedown" listener))
       [ref])
      (when (> refs-count 0)
-       (ui/tippy {:in-editor?      false
-                  :html            (fn [] [:div.mx-2 {:ref ref} (reference/block-linked-references block-uuid)])
-                  :interactive     true
-                  :position        "bottom"
-                  :distance        10
-                  :open?           open?
-                  :popperOptions   {:modifiers {:preventOverflow
-                                                {:enabled           true
-                                                 :boundariesElement "viewport"}}}}
-                 [:div.flex.items-center.gap-2.whiteboard-page-refs-count
-                  {:class (str classname (when open? " open"))
-                   :on-mouse-enter (fn [] (d-open-flag #(if (= % 0) 1 %)))
-                   :on-mouse-leave (fn [] (d-open-flag #(if (= % 2) % 0)))
-                   :on-click (fn [e]
-                               (util/stop e)
-                               (d-open-flag (fn [o] (if (not= o 2) 2 0))))}
-                  [:div.open-page-ref-link refs-count]
-                  (when render-fn (render-fn open?))])))))
+       (dropdown
+        [:div.flex.items-center.gap-2.whiteboard-page-refs-count
+         {:class (str classname (when open? " open"))
+          :on-mouse-enter (fn [] (d-open-flag #(if (= % 0) 1 %)))
+          :on-mouse-leave (fn [] (d-open-flag #(if (= % 2) % 0)))
+          :on-click (fn [e]
+                      (util/stop e)
+                      (d-open-flag (fn [o] (if (not= o 2) 2 0))))}
+         [:div.open-page-ref-link refs-count]
+         (when render-fn (render-fn open?))]
+        (reference/block-linked-references block-uuid)
+        open?
+        #(set-open-flag 0))))))
 
 (defn- get-page-display-name
   [page-name]
@@ -98,19 +114,29 @@
          (util/time-ago (js/Date. updated-at)))))
 
 (rum/defc dashboard-preview-card
-  [page-name]
+  [page-name {:keys [checked on-checked-change show-checked?]}]
   [:div.dashboard-card.dashboard-preview-card.cursor-pointer.hover:shadow-lg
-   {:on-click
+   {:data-checked checked
+    :style {:filter (if (and show-checked? (not checked)) "opacity(0.5)" "none")}
+    :on-click
     (fn [e]
       (util/stop e)
-      (route-handler/redirect-to-whiteboard! page-name))}
+      (if show-checked?
+        (on-checked-change (not checked))
+        (route-handler/redirect-to-whiteboard! page-name)))}
    [:div.dashboard-card-title
-    [:div.flex.w-full
+    [:div.flex.w-full.items-center
      [:div.dashboard-card-title-name.font-bold
       (if (parse-uuid page-name)
         [:span.opacity-50 (t :untitled)]
         (get-page-display-name page-name))]
-     [:div.flex-1]]
+     [:div.flex-1]
+     [:div.dashboard-card-checkbox
+      {:tab-index -1
+       :style {:visibility (when show-checked? "visible")}
+       :on-click util/stop-propagation}
+      (ui/checkbox {:checked checked
+                    :on-change (fn [] (on-checked-change (not checked)))})]]
     [:div.flex.w-full.opacity-50
      [:div (get-page-human-update-time page-name)]
      [:div.flex-1]
@@ -129,22 +155,6 @@
    [:span.dashboard-create-card-caption.select-none
     "New whiteboard"]])
 
-;; TODO: move it to util?
-(defn- use-component-size
-  [ref]
-  (let [[rect set-rect] (rum/use-state nil)]
-    (rum/use-effect!
-     (fn []
-       (let [update-rect #(set-rect (when (.-current ref) (.. ref -current getBoundingClientRect)))
-             updator (fn [entries]
-                       (when (.-contentRect (first (js->clj entries))) (update-rect)))
-             observer (js/ResizeObserver. updator)]
-         (update-rect)
-         (.observe observer (.-current ref))
-         #(.disconnect observer)))
-     [])
-    rect))
-
 (rum/defc whiteboard-dashboard
   []
   (if (user-handler/alpha-user?)
@@ -153,7 +163,7 @@
                            reverse)
           whiteboard-names (map :block/name whiteboards)
           ref (rum/use-ref nil)
-          rect (use-component-size ref)
+          rect (util/use-component-size ref)
           [container-width] (when rect [(.-width rect) (.-height rect)])
           cols (cond (< container-width 600) 1
                      (< container-width 900) 2
@@ -161,12 +171,29 @@
                      :else 4)
           total-whiteboards (count whiteboards)
           empty-cards (- (max (* (math/ceil (/ (inc total-whiteboards) cols)) cols) (* 2 cols))
-                         (inc total-whiteboards))]
+                         (inc total-whiteboards))
+          [checked-page-names set-checked-page-names] (rum/use-state #{})
+          has-checked? (not-empty checked-page-names)]
       [:<>
-       [:h1.title.select-none
+       [:h1.select-none.flex.items-center.whiteboard-dashboard-title.title
         "All whiteboards"
         [:span.opacity-50
-         (str " · " total-whiteboards)]]
+         (str " · " total-whiteboards)]
+        [:div.flex-1]
+        (when has-checked?
+          [:button.ui__button.m-0.py-1.inline-flex.items-center.bg-red-800
+           {:on-click
+            (fn []
+              (state/set-modal! (page/batch-delete-dialog
+                                 (map (fn [name]
+                                        (some (fn [w] (when (= (:block/name w) name) w)) whiteboards))
+                                      checked-page-names)
+                                 false route-handler/redirect-to-whiteboard-dashboard!)))}
+           [:span.flex.gap-2.items-center
+            [:span.opacity-50 (ui/icon "trash" {:style {:font-size 15}})]
+            (t :delete)
+            [:span.opacity-50
+             (str " · " (count checked-page-names))]]])]
        [:div
         {:ref ref}
         [:div.gap-8.grid.grid-rows-auto
@@ -174,7 +201,14 @@
                   :grid-template-columns (str "repeat(" cols ", minmax(0, 1fr))")}}
          (dashboard-create-card)
          (for [whiteboard-name whiteboard-names]
-           [:<> {:key whiteboard-name} (dashboard-preview-card whiteboard-name)])
+           [:<> {:key whiteboard-name}
+            (dashboard-preview-card whiteboard-name
+                                    {:show-checked? has-checked?
+                                     :checked (boolean (checked-page-names whiteboard-name))
+                                     :on-checked-change (fn [checked]
+                                                          (set-checked-page-names (if checked
+                                                                               (conj checked-page-names whiteboard-name)
+                                                                               (disj checked-page-names whiteboard-name))))})])
          (for [n (range empty-cards)]
            [:div.dashboard-card.dashboard-bg-card {:key n}])]]])
     [:div "This feature is not public available yet."]))

+ 32 - 3
src/main/frontend/components/whiteboard.css

@@ -7,6 +7,10 @@
   z-index: -1;
 }
 
+h1.title.whiteboard-dashboard-title {
+  padding-right: 0;
+}
+
 .dashboard-bg-card {
   background-color: var(--ls-secondary-background-color);
   border: 1px solid var(--ls-border-color);
@@ -16,9 +20,31 @@
 .dashboard-card {
   @apply rounded-lg flex flex-col gap-1 overflow-hidden font-medium;
   height: 300px;
+
+  .dashboard-card-checkbox {
+    @apply flex items-center justify-center rounded flex-shrink-0;
+    border: 2px solid transparent;
+    visibility: hidden;
+    width: 24px;
+    height: 24px;
+    transform: translateX(4px);
+
+    &:focus-within {
+      border-color: var(--ls-border-color);
+    }
+
+    .form-checkbox {
+      top: 0;
+    }
+  }
+
+  &:is(:hover, [data-checked='true']) .dashboard-card-checkbox {
+    visibility: visible;
+  }
 }
 
 .dashboard-preview-card {
+  @apply transition;
   border: 1px solid var(--ls-border-color);
 }
 
@@ -44,8 +70,7 @@
 
 .dashboard-create-card:hover {
   background-color: var(--ls-selection-background-color);
-  box-shadow:
-    0 4px 8px -2px rgba(16, 24, 40, 0.1),
+  box-shadow: 0 4px 8px -2px rgba(16, 24, 40, 0.1),
     0 2px 4px -2px rgba(16, 24, 40, 0.06);
   border: 1px solid var(--ls-page-blockquote-border-color);
 }
@@ -74,6 +99,10 @@
   padding: 12px;
 }
 
+.tl-logseq-cp-container > .ls-block {
+  padding: 0;
+}
+
 /**
  * ???
  */
@@ -94,7 +123,7 @@
 
 .whiteboard-page-refs-count:hover,
 .whiteboard-page-refs-count.open {
-  filter: brightness(0.9)
+  filter: brightness(0.9);
 }
 
 .whiteboard-page-title-root {

+ 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}
 

+ 4 - 0
src/main/frontend/handler/route.cljs

@@ -39,6 +39,10 @@
   []
   (redirect! {:to :repos}))
 
+(defn redirect-to-whiteboard-dashboard!
+  []
+  (redirect! {:to :whiteboards}))
+
 (defn redirect-to-page!
   "Must ensure `page-name` is dereferenced (not an alias), or it will create a wrong new page with that name (#3511)."
   ([page-name]

+ 18 - 22
src/main/frontend/handler/whiteboard.cljs

@@ -34,10 +34,6 @@
   []
   js/window.tln)
 
-(defn get-tldr-api
-  []
-  (when (get-tldr-app) js/tln.api))
-
 (defn tldraw-idle?
   "return true when tldraw is active and idle. nil when tldraw is 
    not active."
@@ -171,24 +167,24 @@
        (create-new-whiteboard-page! page-name))
      (create-new-whiteboard-page! nil))))
 
-(defn ->logseq-portal-shape
-  [block-id point]
-  {:blockType "B"
-   :id (str (d/squuid))
-   :compact true
-   :pageId (str block-id)
-   :point point
-   :size [400, 0]
-   :type "logseq-portal"})
-
-(defn add-new-block-portal-shape!
-  "Given the block uuid and the point, add a new shape to the referenced block."
-  [block-uuid client-x client-y]
-  (let [api (get-tldr-api)
-        point (js->clj (.. (get-tldr-app) -viewport (getPagePoint #js[client-x client-y])))
-        shape (->logseq-portal-shape block-uuid point)]
-    (editor-handler/set-blocks-id! [block-uuid])
-    (.createShapes api (clj->js shape))))
+;; (defn ->logseq-portal-shape
+;;   [block-id point]
+;;   {:blockType "B"
+;;    :id (str (d/squuid))
+;;    :compact true
+;;    :pageId (str block-id)
+;;    :point point
+;;    :size [400, 0]
+;;    :type "logseq-portal"})
+
+;; (defn add-new-block-portal-shape!
+;;   "Given the block uuid and the point, add a new shape to the referenced block."
+;;   [block-uuid client-x client-y]
+;;   (let [api (get-tldr-api)
+;;         point (js->clj (.. (get-tldr-app) -viewport (getPagePoint #js[client-x client-y])))
+;;         shape (->logseq-portal-shape block-uuid point)]
+;;     (editor-handler/set-blocks-id! [block-uuid])
+;;     (.createShapes api (clj->js shape))))
 
 (defn- get-whiteboard-blocks
   "Given a page, return all the logseq blocks (exlude all shapes)"

+ 1 - 1
src/main/frontend/state.cljs

@@ -549,7 +549,7 @@ Similar to re-frame subscriptions"
    (and
     (util/electron?)
     ((resolve 'frontend.handler.user/alpha-user?)) ;; using resolve to avoid circular dependency
-    (not (false? (:feature/enable-whiteboards? (sub-config repo)))))))
+    (:feature/enable-whiteboards? (sub-config repo)))))
 
 (defn export-heading-to-list?
   []

+ 35 - 17
src/main/frontend/ui.cljs

@@ -1,36 +1,37 @@
 (ns frontend.ui
-  (:require [clojure.string :as string]
+  (:require ["@logseq/react-tweet-embed" :as react-tweet-embed]
+            ["react-intersection-observer" :as react-intersection-observer]
+            ["react-resize-context" :as Resize]
+            ["react-textarea-autosize" :as TextareaAutosize]
+            ["react-tippy" :as react-tippy]
+            ["react-transition-group" :refer [CSSTransition TransitionGroup]]
+            [cljs-bean.core :as bean]
+            [clojure.string :as string]
+            [datascript.core :as d]
+            [electron.ipc :as ipc]
             [frontend.components.svg :as svg]
             [frontend.context.i18n :refer [t]]
+            [frontend.db-mixins :as db-mixins]
             [frontend.handler.notification :as notification-handler]
+            [frontend.handler.plugin :as plugin-handler]
             [frontend.mixins :as mixins]
+            [frontend.mobile.util :as mobile-util]
+            [frontend.modules.shortcut.config :as shortcut-config]
             [frontend.modules.shortcut.core :as shortcut]
+            [frontend.modules.shortcut.data-helper :as shortcut-helper]
             [frontend.rum :as r]
             [frontend.state :as state]
             [frontend.storage :as storage]
             [frontend.ui.date-picker]
             [frontend.util :as util]
             [frontend.util.cursor :as cursor]
-            [frontend.handler.plugin :as plugin-handler]
-            [cljs-bean.core :as bean]
             [goog.dom :as gdom]
-            [frontend.modules.shortcut.config :as shortcut-config]
-            [frontend.modules.shortcut.data-helper :as shortcut-helper]
-            [promesa.core :as p]
+            [goog.functions :refer [debounce]]
             [goog.object :as gobj]
             [lambdaisland.glogi :as log]
             [medley.core :as medley]
-            [electron.ipc :as ipc]
-            ["react-resize-context" :as Resize]
-            ["react-textarea-autosize" :as TextareaAutosize]
-            ["react-tippy" :as react-tippy]
-            ["react-transition-group" :refer [CSSTransition TransitionGroup]]
-            ["@logseq/react-tweet-embed" :as react-tweet-embed]
-            ["react-intersection-observer" :as react-intersection-observer]
-            [rum.core :as rum]
-            [frontend.db-mixins :as db-mixins]
-            [frontend.mobile.util :as mobile-util]
-            [goog.functions :refer [debounce]]))
+            [promesa.core :as p]
+            [rum.core :as rum]))
 
 (defonce transition-group (r/adapt-class TransitionGroup))
 (defonce css-transition (r/adapt-class CSSTransition))
@@ -1012,3 +1013,20 @@
                                                        (set-visible! in-view?))))})
            ref (.-ref inViewState)]
        (lazy-visible-inner visible? content-fn ref)))))
+
+(rum/defc portal
+  ([children]
+   (portal children #(js/document.createElement "div") false))
+  ([children attach-to prepend?]
+   (let [[portal-anchor set-portal-anchor] (rum/use-state nil)]
+     (rum/use-effect!
+      (fn []
+        (let [div (js/document.createElement "div")
+              attached (or (if (fn? attach-to) (attach-to) attach-to) js/document.body)]
+          (.setAttribute div "data-logseq-portal" (str (d/squuid)))
+          (if prepend? (.prepend attached div) (.append attached div))
+          (set-portal-anchor div)
+          #(.remove div)))
+      [])
+     (when portal-anchor
+       (rum/portal (rum/fragment children) portal-anchor)))))

+ 31 - 0
src/main/frontend/util.cljc

@@ -1401,3 +1401,34 @@
                Math/floor
                int
                (#(str % " " (:name unit) (when (> % 1) "s") " ago"))))))))
+
+#?(:cljs
+   (defn use-component-size
+     [ref]
+     (let [[rect set-rect] (rum/use-state nil)]
+       (rum/use-effect!
+        (if ref
+          (fn []
+            (let [update-rect #(set-rect (when (.-current ref) (.. ref -current getBoundingClientRect)))
+                  updator (fn [entries]
+                            (when (.-contentRect (first (js->clj entries))) (update-rect)))
+                  observer (js/ResizeObserver. updator)]
+              (update-rect)
+              (.observe observer (.-current ref))
+              #(.disconnect observer)))
+          #())
+        [ref])
+       rect)))
+
+#?(:cljs
+   (defn use-click-outside
+     [ref handler]
+     (rum/use-effect!
+      (fn []
+        (let [listener (fn [e]
+                         (when (and (.-current ref)
+                                    (not (.. ref -current (contains (.-target e))) ))
+                           (handler e)))]
+           (js/document.addEventListener "click" listener)
+           #(.removeEventListener js/document "click" listener)))
+      [ref])))

+ 3 - 3
tldraw/apps/tldraw-logseq/package.json

@@ -25,10 +25,10 @@
     "@types/react-dom": "^17.0.0",
     "autoprefixer": "^10.4.7",
     "concurrently": "^7.2.1",
-    "esbuild": "^0.15.6",
-    "mobx": "^6.6.0",
+    "esbuild": "^0.15.7",
+    "mobx": "^6.6.2",
     "mobx-react-lite": "^3.4.0",
-    "perfect-freehand": "^1.1.0",
+    "perfect-freehand": "^1.2.0",
     "polished": "^4.0.0",
     "postcss": "^8.4.14",
     "react": "^17.0.0",

+ 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 })
-  }, [])
-}

+ 292 - 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,217 @@ 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
+  }
+}
 
-      const imageAssetsToCreate: VideoImageAsset[] = []
+// 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()
+}
+
+// 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
-            }
-            if (await handleAssetUrl(dataurl, isVideo)) {
-              added = true
+      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)
             }
-          } 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)
+      }
 
-          shapesToCreate.push({
-            ...HTMLShape.defaultProps,
-            html: rawText,
-            point: [point[0], point[1]],
+      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
+
+          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()
+          const allSelectedBlocks = window.logseq?.api?.get_selected_blocks?.()
+          const blockUUIDs =
+            allSelectedBlocks && allSelectedBlocks?.length > 1
+              ? allSelectedBlocks.map(b => b.uuid)
+              : [text]
+          const tasks = blockUUIDs.map(uuid => tryCreateLogseqPortalShapesFromString(`((${uuid}))`))
+          const newShapes = (await Promise.all(tasks)).flat().filter(isNonNullable)
+          return newShapes.map((s, idx) => {
+            // if there are multiple shapes, shift them to the right
+            return {
+              ...s,
+              // TODO: use better alignment?
+              point: [point[0] + (LogseqPortalShape.defaultProps.size[0] + 16) * idx, point[1]],
+            }
+          })
+        }
+        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 +268,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 +327,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 +440,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 - 0
tldraw/apps/tldraw-logseq/src/index.ts

@@ -10,6 +10,7 @@ declare global {
         edit_block?: (uuid: string) => void
         set_blocks_id?: (uuids: string[]) => void
         open_external_link?: (url: string) => void
+        get_selected_blocks?: () => { uuid: string }[]
       }
     }
   }

+ 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

+ 99 - 42
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,13 +126,38 @@ const useSearch = (q: string, searchFilter: 'B' | 'P' | null) => {
     return () => {
       canceled = true
     }
-  }, [q, handlers?.search])
+  }, [dq, handlers?.search])
 
   return results
 }
 
+const CircleButton = ({
+  active,
+  style,
+  icon,
+  otherIcon,
+  onClick,
+}: {
+  active?: boolean
+  style?: React.CSSProperties
+  icon: string
+  otherIcon?: string
+  onClick: () => void
+}) => {
+  return (
+    <div data-active={active} style={style} className="tl-circle-button" onMouseDown={onClick}>
+      <div className="tl-circle-button-icons-wrapper" data-icons-count={otherIcon ? 2 : 1}>
+        {otherIcon && <TablerIcon name={otherIcon} />}
+        <TablerIcon name={icon} />
+      </div>
+    </div>
+  )
+}
+
 export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
   static id = 'logseq-portal'
+  static defaultSearchQuery = ''
+  static defaultSearchFilter: 'B' | 'P' | null = null
 
   static defaultProps: LogseqPortalShapeProps = {
     id: 'logseq-portal',
@@ -199,7 +226,14 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
     } else {
       const originalHeight = this.props.size[1]
       this.canResize[1] = !collapsed
+      console.log(
+        collapsed,
+        collapsed ? this.getHeaderHeight() : this.props.collapsedHeight,
+        this.getHeaderHeight(),
+        this.props.collapsedHeight
+      )
       this.update({
+        isAutoResizing: !collapsed,
         collapsed: collapsed,
         size: [this.props.size[0], collapsed ? this.getHeaderHeight() : this.props.collapsedHeight],
         collapsedHeight: collapsed ? originalHeight : this.props.collapsedHeight,
@@ -303,7 +337,10 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
   }
 
   LogseqQuickSearch = observer(({ onChange }: LogseqQuickSearchProps) => {
-    const [q, setQ] = React.useState('')
+    const [q, setQ] = React.useState(LogseqPortalShape.defaultSearchQuery)
+    const [searchFilter, setSearchFilter] = React.useState<'B' | 'P' | null>(
+      LogseqPortalShape.defaultSearchFilter
+    )
     const rInput = React.useRef<HTMLInputElement>(null)
     const { handlers, renderers } = React.useContext(LogseqContext)
     const app = useApp<Shape>()
@@ -311,6 +348,10 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
     const finishCreating = React.useCallback((id: string) => {
       onChange(id)
       rInput.current?.blur()
+      if (id) {
+        LogseqPortalShape.defaultSearchQuery = ''
+        LogseqPortalShape.defaultSearchFilter = null
+      }
     }, [])
 
     const onAddBlock = React.useCallback((content: string) => {
@@ -330,7 +371,6 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
 
     const [focusedOptionIdx, setFocusedOptionIdx] = React.useState<number>(0)
 
-    const [searchFilter, setSearchFilter] = React.useState<'B' | 'P' | null>(null)
     const searchResult = useSearch(q, searchFilter)
 
     const [prefixIcon, setPrefixIcon] = React.useState<string>('circle-plus')
@@ -342,6 +382,11 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
       })
     }, [searchFilter])
 
+    React.useEffect(() => {
+      LogseqPortalShape.defaultSearchQuery = q
+      LogseqPortalShape.defaultSearchFilter = searchFilter
+    }, [q, searchFilter])
+
     type Option = {
       actionIcon: 'search' | 'circle-plus'
       onChosen: () => boolean // return true if the action was handled
@@ -505,6 +550,8 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
           e.preventDefault()
         } else if (e.key === 'Backspace' && q.length === 0) {
           setSearchFilter(null)
+        } else if (e.key === 'Escape') {
+          finishCreating('')
         }
 
         if (newIndex !== focusedOptionIdx) {
@@ -530,9 +577,12 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
 
     return (
       <div className="tl-quick-search">
-        <div className="tl-quick-search-indicator">
-          <TablerIcon name={prefixIcon} className="tl-quick-search-icon" />
-        </div>
+        <CircleButton
+          icon={prefixIcon}
+          onClick={() => {
+            options[focusedOptionIdx]?.onChosen()
+          }}
+        />
         <div className="tl-quick-search-input-container">
           {searchFilter && (
             <div className="tl-quick-search-input-filter">
@@ -653,7 +703,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 +797,7 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
         }}
         {...events}
       >
+        {isBinding && <BindingIndicator mode="html" strokeWidth={strokeWidth} size={size} />}
         <div
           onWheelCapture={stop}
           onPointerDown={stop}
@@ -760,38 +811,44 @@ export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
           {isCreating ? (
             <LogseqQuickSearch onChange={onPageNameChanged} />
           ) : (
-            <div
-              className="tl-logseq-portal-container"
-              data-collapsed={this.props.collapsed}
-              data-page-id={pageId}
-              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})`,
-                transform: `scale(${scaleRatio})`,
-                // @ts-expect-error ???
-                '--ls-primary-background-color': !fill?.startsWith('var') ? fill : undefined,
-                '--ls-primary-text-color': !stroke?.startsWith('var') ? stroke : undefined,
-                '--ls-title-text-color': !stroke?.startsWith('var') ? stroke : undefined,
-              }}
-            >
-              {!this.props.compact && !targetNotFound && (
-                <LogseqPortalShapeHeader type={this.props.blockType ?? 'P'}>
-                  {this.props.blockType === 'P' ? (
-                    <PageNameLink pageName={pageId} />
-                  ) : (
-                    <Breadcrumb blockId={pageId} />
-                  )}
-                </LogseqPortalShapeHeader>
-              )}
-              {targetNotFound && <div className="tl-target-not-found">Target not found</div>}
-              {showingPortal && <PortalComponent {...componentProps} />}
-            </div>
+            <>
+              <div
+                className="tl-logseq-portal-container"
+                data-collapsed={this.collapsed}
+                data-page-id={pageId}
+                data-portal-selected={portalSelected}
+                style={{
+                  background: this.props.compact ? 'transparent' : fill,
+                  color: stroke,
+                  width: `calc(100% / ${scaleRatio})`,
+                  height: `calc(100% / ${scaleRatio})`,
+                  transform: `scale(${scaleRatio})`,
+                  // @ts-expect-error ???
+                  '--ls-primary-background-color': !fill?.startsWith('var') ? fill : undefined,
+                  '--ls-primary-text-color': !stroke?.startsWith('var') ? stroke : undefined,
+                  '--ls-title-text-color': !stroke?.startsWith('var') ? stroke : undefined,
+                }}
+              >
+                {!this.props.compact && !targetNotFound && (
+                  <LogseqPortalShapeHeader type={this.props.blockType ?? 'P'}>
+                    {this.props.blockType === 'P' ? (
+                      <PageNameLink pageName={pageId} />
+                    ) : (
+                      <Breadcrumb blockId={pageId} />
+                    )}
+                  </LogseqPortalShapeHeader>
+                )}
+                {targetNotFound && <div className="tl-target-not-found">Target not found</div>}
+                {showingPortal && <PortalComponent {...componentProps} />}
+              </div>
+              <CircleButton
+                active={!!this.collapsed}
+                style={{ opacity: isSelected ? 1 : 0 }}
+                icon={this.props.blockType === 'B' ? 'block' : 'page'}
+                onClick={() => this.setCollapsed(!this.collapsed)}
+                otherIcon={'whiteboard-element'}
+              />
+            </>
           )}
         </div>
       </HTMLContainer>

+ 1 - 0
tldraw/apps/tldraw-logseq/src/lib/tools/LogseqPortalTool/states/CreatingState.tsx

@@ -29,6 +29,7 @@ export class CreatingState extends TLToolState<
       this.creatingShape = shape
       this.app.currentPage.addShapes(shape)
       this.app.setEditingShape(shape)
+      this.app.setSelectedShapes([shape])
       if (this.app.viewport.camera.zoom < 0.8 || this.app.viewport.camera.zoom > 1.2) {
         this.app.api.resetZoomToCursor()
       }

+ 59 - 10
tldraw/apps/tldraw-logseq/src/styles.css

@@ -497,20 +497,70 @@ button.tl-select-input-trigger {
   position: relative;
 }
 
-.tl-quick-search-icon {
-  flex-shrink: 0;
-}
-
-.tl-quick-search-indicator {
-  @apply absolute flex items-center	justify-center;
+.tl-circle-button {
+  @apply absolute flex items-center	justify-center transition-all;
 
+  color: var(--ls-primary-text-color);
   background-color: var(--ls-secondary-background-color);
   font-size: 22px;
   right: calc(100% + 12px);
   height: 34px;
   width: 34px;
   border-radius: 50%;
-  top: calc(50% - 17px);
+  border: 2px solid var(--ls-secondary-background-color);
+  top: 2px;
+
+  .tie {
+    transform: translateY(-100%); 
+  }
+
+  &[data-active='false']:hover {
+    .tie {
+      transform: translateY(0);
+
+      &:first-of-type {
+        opacity: 0.6;
+      }
+    }
+  }
+
+  &[data-active='true'] {
+    background-color: var(--ls-active-primary-color);
+    color: var(--ls-block-highlight-color);
+    border: 2px solid var(--ls-active-primary-color);
+
+    .tie {
+      transform: translateY(0);
+
+      &:last-of-type {
+        opacity: 0.6;
+      }
+    }
+
+    &:hover {
+      color: var(--ls-primary-text-color);
+      background-color: var(--ls-secondary-background-color);
+
+      .tie {
+        transform: translateY(-100%);
+      }
+    }
+  }
+
+  .tl-circle-button-icons-wrapper {
+    @apply flex flex-col;
+
+    > i.tie {
+      transition: transform 0.2s ease-in-out;
+    }
+  }
+
+  .tl-circle-button-icons-wrapper[data-icons-count='2'] {
+    position: relative;
+    width: 22px;
+    height: 22px;
+    overflow: hidden;
+  }
 }
 
 .tl-quick-search-input-container {
@@ -624,7 +674,6 @@ button.tl-select-input-trigger {
 
   top: 0;
   left: 0;
-  transform: translate(1px, 1px);
   overscroll-behavior: none;
   opacity: 1;
   user-select: text;
@@ -829,12 +878,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)));
 }

+ 2 - 2
tldraw/demo/package.json

@@ -7,13 +7,13 @@
     "autoprefixer": "^10.4.7",
     "postcss": "^8.4.13",
     "tailwindcss": "^3.0.24",
-    "vite": "^3.0.9"
+    "vite": "^3.1.2"
   },
   "scripts": {
     "dev": "vite"
   },
   "dependencies": {
-    "@vitejs/plugin-react": "^2.0.0",
+    "@vitejs/plugin-react": "^2.1.0",
     "react": "^17",
     "react-dom": "^17"
   }

+ 2 - 2
tldraw/package.json

@@ -30,9 +30,9 @@
     "@types/react-dom": "^17.0.0",
     "@typescript-eslint/eslint-plugin": "^5.36.1",
     "@typescript-eslint/parser": "^5.36.1",
-    "eslint": "^8.23.0",
+    "eslint": "^8.23.1",
     "init-package-json": "^3.0.2",
-    "lerna": "^5.5.0",
+    "lerna": "^5.5.1",
     "lint-staged": "^13.0.1",
     "prettier": "^2.6.2",
     "prettier-plugin-jsdoc": "^0.3.38",

+ 4 - 3
tldraw/packages/core/package.json

@@ -37,14 +37,15 @@
   "dependencies": {
     "@tldraw/intersect": "2.0.0-alpha.1",
     "@tldraw/vec": "2.0.0-alpha.1",
-    "@use-gesture/react": "^10.2.19",
+    "@use-gesture/react": "^10.2.20",
     "fast-copy": "^2.1.3",
     "fast-deep-equal": "^3.1.3",
-    "hotkeys-js": "^3.9.5",
+    "hotkeys-js": "^3.10.0",
     "is-plain-object": "^5.0.0",
-    "mobx": "^6.6.0",
+    "mobx": "^6.6.2",
     "mobx-react-lite": "^3.4.0",
     "mousetrap": "^1.6.5",
+    "proxy-compare": "^2.3.0",
     "rbush": "^3.0.1",
     "uuid": "^8.0.0"
   },

+ 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,
-    }
-  }
 }

+ 2 - 0
tldraw/packages/core/src/lib/TLPage/TLPage.ts

@@ -35,6 +35,8 @@ export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventM
     this.addShapes(...shapes)
     makeObservable(this)
 
+    // Performance bottleneck!! Optimize me :/
+    // Instead of watch for every shape change, we should only watch for the changed ones
     reaction(
       () => ({
         id: this.id,

+ 2 - 0
tldraw/packages/core/src/lib/tools/TLSelectTool/states/IdleState.ts

@@ -126,6 +126,8 @@ export class IdleState<
     const selectedShape = this.app.selectedShapesArray[0]
     if (!selectedShape.canEdit) return
 
+    if (!PointUtils.pointInBounds(this.app.inputs.currentPoint, selectedShape.bounds)) return
+
     switch (info.type) {
       case TLTargetType.Shape: {
         this.tool.transition('editingShape', info)

+ 4 - 2
tldraw/packages/core/src/lib/tools/TLSelectTool/states/PointingSelectedShapeState.ts

@@ -5,6 +5,7 @@ import {
   type TLEvents,
   TLTargetType,
 } from '../../../../types'
+import { PointUtils } from '../../../../utils'
 import { type TLShape, TLBoxShape } from '../../../shapes'
 import type { TLApp } from '../../../TLApp'
 import { TLToolState } from '../../../TLToolState'
@@ -40,7 +41,7 @@ export class PointingSelectedShapeState<
   }
 
   onPointerUp: TLEvents<S>['pointer'] = () => {
-    const { shiftKey } = this.app.inputs
+    const { shiftKey, currentPoint } = this.app.inputs
     const { selectedShapesArray } = this.app
     if (!this.pointedSelectedShape) throw Error('Expected a pointed selected shape')
     if (shiftKey) {
@@ -51,7 +52,8 @@ export class PointingSelectedShapeState<
     } else if (
       selectedShapesArray.length === 1 &&
       this.pointedSelectedShape.canEdit &&
-      this.pointedSelectedShape instanceof TLBoxShape
+      this.pointedSelectedShape instanceof TLBoxShape &&
+      PointUtils.pointInBounds(currentPoint, this.pointedSelectedShape.bounds)
     ) {
       this.tool.transition('editingShape', {
         shape: this.pointedSelectedShape,

+ 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
+}

+ 30 - 0
tldraw/packages/core/src/utils/cache.ts

@@ -0,0 +1,30 @@
+export class SimpleCache<T extends object, K> {
+  items = new WeakMap<T, K>()
+
+  get<P extends T>(item: P, cb: (item: P) => K) {
+    if (!this.items.has(item)) {
+      this.items.set(item, cb(item))
+    }
+    return this.items.get(item)!
+  }
+
+  access(item: T) {
+    return this.items.get(item)
+  }
+
+  set(item: T, value: K) {
+    this.items.set(item, value)
+  }
+
+  has(item: T) {
+    return this.items.has(item)
+  }
+
+  invalidate(item: T) {
+    this.items.delete(item)
+  }
+
+  bust() {
+    this.items = new WeakMap()
+  }
+}

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

@@ -5,9 +5,11 @@ export * from './KeyUtils'
 export * from './GeomUtils'
 export * from './PolygonUtils'
 export * from './SvgPathUtils'
+export * from './BindingUtils'
 export * from './DataUtils'
 export * from './TextUtils'
 export * from './getTextSize'
+export * from './cache'
 
 export function uniqueId() {
   return uuid.v1()

+ 3 - 3
tldraw/packages/react/package.json

@@ -35,10 +35,10 @@
     "@tldraw/core": "2.0.0-alpha.1",
     "@tldraw/intersect": "2.0.0-alpha.1",
     "@tldraw/vec": "2.0.0-alpha.1",
-    "@use-gesture/react": "^10.2.19",
-    "hotkeys-js": "^3.9.5",
+    "@use-gesture/react": "^10.2.20",
+    "hotkeys-js": "^3.10.0",
     "is-plain-object": "^5.0.0",
-    "mobx": "^6.6.0",
+    "mobx": "^6.6.2",
     "mobx-react-lite": "^3.4.0",
     "mousetrap": "^1.6.5",
     "rbush": "^3.0.1",

+ 2 - 0
tldraw/packages/react/src/components/ui/SelectionBackground/SelectionBackground.tsx

@@ -16,6 +16,8 @@ export const SelectionBackground = observer(function SelectionBackground<S exten
         width={Math.max(1, bounds.width)}
         height={Math.max(1, bounds.height)}
         pointerEvents="all"
+        rx={8}
+        ry={8}
       />
     </SVGContainer>
   )

+ 1 - 5
tldraw/packages/react/src/components/ui/SelectionForeground/SelectionForeground.tsx

@@ -24,12 +24,8 @@ export const SelectionForeground = observer(function SelectionForeground<S exten
 
   const editing = !!app.editingShape
 
-  // when editing, make the selection a bit larger
-  width = editing ? width + 2 : width
-  height = editing ? height + 2 : height
-
   return (
-    <SVGContainer style={{ transform: editing ? 'translate(-1px, -1px)' : 'none' }}>
+    <SVGContainer>
       <rect
         className="tl-bounds-fg"
         width={Math.max(width, 1)}

+ 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'>
 }

File diff suppressed because it is too large
+ 394 - 347
tldraw/yarn.lock


Some files were not shown because too many files changed in this diff