Browse Source

Feat: Export to image (#9037)

* feat: export to image

* chore: export selection on whiteboards

* fix: whiteboards zoom on export

* fix: loading position

* chore: support video thumb

* core: add export to  whiteboards context menu

* fix: context menu entry

* fix; copy image to clipboard

* fix: copy / export label

* fix: hide ui elements

* fix: remove random character

* fix: graph export

* chore: remove console log and jpg format

* style: run prettier

* fix: disable on multiple selected blocks

* fix: multiple blocks

* enhance: restrict bounds of selected shapes

* chore: export selection on whiteboards

* fix: whiteboards zoom on export

* chore: support video thumb

* core: add export to  whiteboards context menu

* fix: context menu entry

* fix; copy image to clipboard

* fix: copy / export label

* fix: hide ui elements

* fix: remove random character

* fix: graph export

* chore: remove console log and jpg format

* style: run prettier

* fix: disable on multiple selected blocks

* fix: multiple blocks

* enhance: restrict bounds of selected shapes

* Fix any html2canvas related functionality failing in publishing

* fix: portal header gradient on export

* chore: add comment about html2canvas-ignore attr

* fix: use export padding constant

* fix: export collapsed portals with size >  medium

* fix: reset export type

* enhance: export filename

---------

Co-authored-by: Gabriel Horner <[email protected]>
Co-authored-by: Tienson Qin <[email protected]>
Konstantinos 2 years ago
parent
commit
95149e13f6
34 changed files with 350 additions and 149 deletions
  1. 2 1
      deps/publishing/src/logseq/publishing/html.cljs
  2. 1 0
      public/index.html
  3. 5 0
      resources/css/common.css
  4. 1 0
      resources/electron.html
  5. 1 0
      resources/index.html
  6. 19 0
      resources/js/html2canvas.min.js
  7. 4 4
      src/main/frontend/components/content.cljs
  8. 201 122
      src/main/frontend/components/export.cljs
  9. 8 0
      src/main/frontend/components/export.css
  10. 12 1
      src/main/frontend/components/page.cljs
  11. 2 2
      src/main/frontend/components/page_menu.cljs
  12. 1 0
      src/main/frontend/components/whiteboard.cljs
  13. 1 1
      src/main/frontend/dicts.cljc
  14. 2 0
      src/main/frontend/extensions/tldraw.cljs
  15. 19 0
      src/main/frontend/utils.js
  16. 1 1
      tldraw/apps/tldraw-logseq/src/components/ActionBar/ActionBar.tsx
  17. 1 0
      tldraw/apps/tldraw-logseq/src/components/Button/CircleButton.tsx
  18. 24 2
      tldraw/apps/tldraw-logseq/src/components/ContextMenu/ContextMenu.tsx
  19. 1 1
      tldraw/apps/tldraw-logseq/src/components/PrimaryTools/PrimaryTools.tsx
  20. 1 1
      tldraw/apps/tldraw-logseq/src/components/StatusBar/StatusBar.tsx
  21. 1 0
      tldraw/apps/tldraw-logseq/src/lib/logseq-context.ts
  22. 6 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/LogseqPortalShape.tsx
  23. 3 1
      tldraw/apps/tldraw-logseq/src/lib/shapes/YouTubeShape.tsx
  24. 1 4
      tldraw/apps/tldraw-logseq/src/styles.css
  25. 2 0
      tldraw/packages/core/src/constants.ts
  26. 10 2
      tldraw/packages/react/src/components/Canvas/Canvas.tsx
  27. 6 1
      tldraw/packages/react/src/components/ContextBarContainer/ContextBarContainer.tsx
  28. 1 0
      tldraw/packages/react/src/components/Indicator/Indicator.tsx
  29. 1 1
      tldraw/packages/react/src/components/QuickLinksContainer/QuickLinksContainer.tsx
  30. 1 0
      tldraw/packages/react/src/components/SelectionDetailContainer/SelectionDetailContainer.tsx
  31. 3 0
      tldraw/packages/react/src/components/Shape/Shape.tsx
  32. 1 1
      tldraw/packages/react/src/components/ui/DirectionIndicator/DirectionIndicator.tsx
  33. 6 1
      tldraw/packages/react/src/components/ui/Grid/Grid.tsx
  34. 1 1
      tldraw/packages/react/src/components/ui/SelectionBackground/SelectionBackground.tsx

+ 2 - 1
deps/publishing/src/logseq/publishing/html.cljs

@@ -122,9 +122,10 @@ necessary db filtering"
       }(window.location))"]
             ;; TODO: should make this configurable
             [:script {:src "static/js/main.js"}]
-            [:script {:src "static/js/highlight.min.js"}]
             [:script {:src "static/js/interact.min.js"}]
+            [:script {:src "static/js/highlight.min.js"}]
             [:script {:src "static/js/katex.min.js"}]
+            [:script {:src "static/js/html2canvas.min.js"}]
             [:script {:src "static/js/code-editor.js"}]])))))
 
 (defn build-html

+ 1 - 0
public/index.html

@@ -50,6 +50,7 @@
 </script>
 <script defer src="/static/js/highlight.min.js"></script>
 <script defer src="/static/js/interact.min.js"></script>
+<script defer src="/static/js/html2canvas.min.js"></script>
 <script defer src="/static/js/main.js"></script>
 <script defer src="/static/js/amplify.js"></script>
 <script defer src="/static/js/tabler.min.js"></script>

+ 5 - 0
resources/css/common.css

@@ -921,4 +921,9 @@ html.is-mobile {
         @apply grid grid-flow-col auto-cols-max;
         place-items: center;
     }
+
+  /* fixes an html2canvas issue */
+  img {
+    @apply inline-block;
+  }
 }

+ 1 - 0
resources/electron.html

@@ -50,6 +50,7 @@ const portal = new MagicPortal(worker);
 </script>
 <script defer src="./js/highlight.min.js"></script>
 <script defer src="./js/interact.min.js"></script>
+<script defer src="./js/html2canvas.min.js"></script>
 <script defer src="./js/lsplugin.core.js"></script>
 <script defer src="./js/main.js"></script>
 <script defer src="./js/amplify.js"></script>

+ 1 - 0
resources/index.html

@@ -49,6 +49,7 @@ const portal = new MagicPortal(worker);
 </script>
 <script defer src="./js/highlight.min.js"></script>
 <script defer src="./js/interact.min.js"></script>
+<script defer src="./js/html2canvas.min.js"></script>
 <script defer src="./js/lsplugin.core.js"></script>
 <script defer src="./js/main.js"></script>
 <script defer src="./js/amplify.js"></script>

File diff suppressed because it is too large
+ 19 - 0
resources/js/html2canvas.min.js


+ 4 - 4
src/main/frontend/components/content.cljs

@@ -61,8 +61,8 @@
      :on-click (fn [_]
                  (let [block-uuids (editor-handler/get-selected-toplevel-block-uuids)]
                    (state/set-modal!
-                    #(export/export-blocks block-uuids))))}
-    (t :content/copy-as)
+                    #(export/export-blocks block-uuids {:whiteboard? false}))))}
+    (t :content/copy-export-as)
     nil)
    (ui/menu-link
     {:key "copy block refs"
@@ -218,8 +218,8 @@
          (ui/menu-link
           {:key      "Copy as"
            :on-click (fn [_]
-                       (state/set-modal! #(export/export-blocks [block-id])))}
-          (t :content/copy-as)
+                       (state/set-modal! #(export/export-blocks [block-id] {:whiteboard? false})))}
+          (t :content/copy-export-as)
           nil)
 
          (ui/menu-link

+ 201 - 122
src/main/frontend/components/export.cljs

@@ -1,9 +1,13 @@
 (ns frontend.components.export
-  (:require [frontend.context.i18n :refer [t]]
+  (:require [cljs-time.core :as t]
+            ["/frontend/utils" :as utils]
+            [frontend.context.i18n :refer [t]]
+            [frontend.db :as db]
             [frontend.handler.export.text :as export-text]
             [frontend.handler.export.html :as export-html]
             [frontend.handler.export.opml :as export-opml]
             [frontend.handler.export :as export]
+            [frontend.image :as image]
             [frontend.mobile.util :as mobile-util]
             [frontend.state :as state]
             [frontend.ui :as ui]
@@ -74,6 +78,46 @@
              current-repo block-uuids-or-page-name {:remove-options text-remove-options :other-options text-other-options})
       "")))
 
+(defn- get-zoom-level
+  [page-uuid]
+  (let [uuid (:block/uuid (db/get-page page-uuid))
+        whiteboard-camera (->> (str "logseq.tldraw.camera:" uuid)
+                               (.getItem js/sessionStorage)
+                               (js/JSON.parse)
+                               (js->clj))]
+    (or (get whiteboard-camera "zoom") 1)))
+
+(defn- get-image-blob
+  [block-uuids-or-page-name {:keys [transparent-bg? x y width height zoom]} callback]
+  (let [html js/document.body.parentNode
+        style (js/window.getComputedStyle html)
+        background (when-not transparent-bg? (.getPropertyValue style "--ls-primary-background-color"))
+        page? (string? block-uuids-or-page-name)
+        selector (if page?
+                   "#main-content-container"
+                   (str "[blockid='" (str (first block-uuids-or-page-name)) "']"))
+        container  (js/document.querySelector selector)
+        scale (if page? (/ 1 (or zoom (get-zoom-level block-uuids-or-page-name))) 1)
+        options #js {:allowTaint true
+                     :useCORS true
+                     :backgroundColor (or background "transparent")
+                     :x (or (/ x scale) 0)
+                     :y (or (/ y scale) 0)
+                     :width (when width (/ width scale))
+                     :height (when height (/ height scale))
+                     :scrollX 0
+                     :scrollY 0
+                     :scale scale
+                     :windowHeight (when (string? block-uuids-or-page-name)
+                                     (.-scrollHeight container))}]
+    (-> (js/html2canvas container options)
+        (.then (fn [canvas] (.toBlob canvas (fn [blob]
+                                              (when blob
+                                                (let [img (js/document.getElementById "export-preview")
+                                                      img-url (image/create-object-url blob)]
+                                                  (set! (.-src img) img-url)
+                                                  (callback blob)))) "image/png"))))))
+
 (rum/defcs ^:large-vars/cleanup-todo
   export-blocks < rum/static
   (rum/local false ::copied?)
@@ -82,13 +126,18 @@
   (rum/local nil ::text-other-options)
   (rum/local nil ::content)
   {:will-mount (fn [state]
-                 (let [content (export-helper (last (:rum/args state)))]
-                   (reset! (::content state) content)
-                   (reset! (::text-remove-options state) (set (state/get-export-block-text-remove-options)))
-                   (reset! (::text-indent-style state) (state/get-export-block-text-indent-style))
-                   (reset! (::text-other-options state) (state/get-export-block-text-other-options))
-                   state))}
-  [state root-block-uuids-or-page-name]
+                 (reset! *export-block-type (if (:whiteboard? (last (:rum/args state))) :png :text))
+                 (if (= @*export-block-type :png)
+                   (do (reset! (::content state) nil)
+                       (get-image-blob (first (:rum/args state))
+                                       (merge (second (:rum/args state)) {:transparent-bg? false})
+                                       (fn [blob] (reset! (::content state) blob))))
+                   (reset! (::content state) (export-helper (first (:rum/args state)))))
+                 (reset! (::text-remove-options state) (set (state/get-export-block-text-remove-options)))
+                 (reset! (::text-indent-style state) (state/get-export-block-text-indent-style))
+                 (reset! (::text-other-options state) (state/get-export-block-text-other-options))
+                 state)}
+  [state root-block-uuids-or-page-name {:keys [whiteboard?] :as options}]
   (let [tp @*export-block-type
         *text-other-options (::text-other-options state)
         *text-remove-options (::text-remove-options state)
@@ -96,117 +145,147 @@
         *copied? (::copied? state)
         *content (::content state)]
     [:div.export.resize
-     [:div.flex
-      {:class "mb-2"}
-      (ui/button "Text"
-                 :class "mr-4 w-20"
-                 :on-click #(do (reset! *export-block-type :text)
-                                (reset! *content (export-helper root-block-uuids-or-page-name))))
-      (ui/button "OPML"
-                 :class "mr-4 w-20"
-                 :on-click #(do (reset! *export-block-type :opml)
-                                (reset! *content (export-helper root-block-uuids-or-page-name))))
-      (ui/button "HTML"
-                 :class "w-20"
-                 :on-click #(do (reset! *export-block-type :html)
-                                (reset! *content (export-helper root-block-uuids-or-page-name))))]
-     [:textarea.overflow-y-auto.h-96 {:value @*content :read-only true}]
-     (let [options (->> text-indent-style-options
-                        (mapv (fn [opt]
-                                (if (= @*text-indent-style (:label opt))
-                                  (assoc opt :selected true)
-                                  opt))))]
-       [:div [:div.flex.items-center
-              [:label.mr-4
-               {:style {:visibility (if (= :text tp) "visible" "hidden")}}
-               "Indentation style:"]
-              [:select.block.my-2.text-lg.rounded.border.py-0.px-1
-               {:style     {:visibility (if (= :text tp) "visible" "hidden")}
-                :on-change (fn [e]
-                             (let [value (util/evalue e)]
-                               (state/set-export-block-text-indent-style! value)
-                               (reset! *text-indent-style value)
-                               (reset! *content (export-helper root-block-uuids-or-page-name))))}
-               (for [{:keys [label value selected]} options]
-                 [:option (cond->
-                           {:key   label
-                            :value (or value label)}
-                            selected
-                            (assoc :selected selected))
-                  label])]]
-        [:div.flex.items-center
-         (ui/checkbox {:class "mr-2"
-                       :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
-                       :checked (contains? @*text-remove-options :page-ref)
-                       :on-change (fn [e]
-                                    (state/update-export-block-text-remove-options! e :page-ref)
-                                    (reset! *text-remove-options (state/get-export-block-text-remove-options))
-                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
-         [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
-          "[[text]] -> text"]
-
-         (ui/checkbox {:class "mr-2 ml-4"
-                       :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
-                       :checked (contains? @*text-remove-options :emphasis)
-                       :on-change (fn [e]
-                                    (state/update-export-block-text-remove-options! e :emphasis)
-                                    (reset! *text-remove-options (state/get-export-block-text-remove-options))
-                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
-
-         [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
-          "remove emphasis"]
-
-         (ui/checkbox {:class "mr-2 ml-4"
-                       :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
-                       :checked (contains? @*text-remove-options :tag)
-                       :on-change (fn [e]
-                                    (state/update-export-block-text-remove-options! e :tag)
-                                    (reset! *text-remove-options (state/get-export-block-text-remove-options))
-                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
-
-         [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
-          "remove #tags"]]
-
-        [:div.flex.items-center
-         (ui/checkbox {:class "mr-2"
-                       :style {:visibility (if (#{:text} tp) "visible" "hidden")}
-                       :checked (boolean (:newline-after-block @*text-other-options))
-                       :on-change (fn [e]
-                                    (state/update-export-block-text-other-options!
-                                     :newline-after-block (boolean (util/echecked? e)))
-                                    (reset! *text-other-options (state/get-export-block-text-other-options))
-                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
-         [:div {:style {:visibility (if (#{:text} tp) "visible" "hidden")}}
-          "newline after block"]
-
-         (ui/checkbox {:class "mr-2 ml-4"
-                       :style {:visibility (if (#{:text} tp) "visible" "hidden")}
-                       :checked (contains? @*text-remove-options :property)
-                       :on-change (fn [e]
-                                    (state/update-export-block-text-remove-options! e :property)
-                                    (reset! *text-remove-options (state/get-export-block-text-remove-options))
-                                    (reset! *content (export-helper root-block-uuids-or-page-name)))})
-         [:div {:style {:visibility (if (#{:text} tp) "visible" "hidden")}}
-          "remove properties"]]
-
-        [:div.flex.items-center
-         [:label.mr-2 {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
-          "level <="]
-         [:select.block.my-2.text-lg.rounded.border.px-2.py-0
-          {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
-           :value (or (:keep-only-level<=N @*text-other-options) :all)
-           :on-change (fn [e]
-                        (let [value (util/evalue e)
-                              level (if (= "all" value) :all (util/safe-parse-int value))]
-                          (state/update-export-block-text-other-options! :keep-only-level<=N level)
-                          (reset! *text-other-options (state/get-export-block-text-other-options))
-                          (reset! *content (export-helper root-block-uuids-or-page-name))))}
-          (for [n (cons "all" (range 1 10))]
-            [:option {:key n :value n} n])]]])
-
-     [:div.mt-4
-      (ui/button (if @*copied? "Copied to clipboard!" "Copy to clipboard")
-                 :on-click (fn []
-                             (util/copy-to-clipboard! @*content
-                                                      :html (when (= tp :html) @*content))
-                             (reset! *copied? true)))]]))
+     (when-not whiteboard?
+       [:div.flex
+        {:class "mb-2"}
+        (ui/button "Text"
+                   :class "mr-4 w-20"
+                   :on-click #(do (reset! *export-block-type :text)
+                                  (reset! *content (export-helper root-block-uuids-or-page-name))))
+        (ui/button "OPML"
+                   :class "mr-4 w-20"
+                   :on-click #(do (reset! *export-block-type :opml)
+                                  (reset! *content (export-helper root-block-uuids-or-page-name))))
+        (ui/button "HTML"
+                   :class "mr-4 w-20"
+                   :on-click #(do (reset! *export-block-type :html)
+                                  (reset! *content (export-helper root-block-uuids-or-page-name))))
+        (when-not (seq? root-block-uuids-or-page-name)
+          (ui/button "PNG"
+                     :class "w-20"
+                     :on-click #(do (reset! *export-block-type :png)
+                                    (reset! *content nil)
+                                    (get-image-blob root-block-uuids-or-page-name (merge options {:transparent-bg? false}) (fn [blob] (reset! *content blob))))))])
+
+     (if (= :png tp)
+       [:div.flex.items-center.justify-center.relative
+        (when (not @*content) [:div.absolute (ui/loading "")])
+        [:img {:alt "export preview" :id "export-preview" :class "my-4" :style {:visibility (when (not @*content) "hidden")}}]]
+
+       [:textarea.overflow-y-auto.h-96 {:value @*content :read-only true}])
+
+     (if (= :png tp)
+       [:div.flex.items-center
+        [:div "Transparent background"]
+        (ui/checkbox {:class "mr-2 ml-4"
+                      :on-change (fn [e]
+                                   (reset! *content nil)
+                                   (get-image-blob root-block-uuids-or-page-name (merge options {:transparent-bg? e.currentTarget.checked}) (fn [blob] (reset! *content blob))))})]
+       (let [options (->> text-indent-style-options
+                          (mapv (fn [opt]
+                                  (if (= @*text-indent-style (:label opt))
+                                    (assoc opt :selected true)
+                                    opt))))]
+         [:div [:div.flex.items-center
+                [:label.mr-4
+                 {:style {:visibility (if (= :text tp) "visible" "hidden")}}
+                 "Indentation style:"]
+                [:select.block.my-2.text-lg.rounded.border.py-0.px-1
+                 {:style     {:visibility (if (= :text tp) "visible" "hidden")}
+                  :on-change (fn [e]
+                               (let [value (util/evalue e)]
+                                 (state/set-export-block-text-indent-style! value)
+                                 (reset! *text-indent-style value)
+                                 (reset! *content (export-helper root-block-uuids-or-page-name))))}
+                 (for [{:keys [label value selected]} options]
+                   [:option (cond->
+                             {:key   label
+                              :value (or value label)}
+                              selected
+                              (assoc :selected selected))
+                    label])]]
+          [:div.flex.items-center
+           (ui/checkbox {:class "mr-2"
+                         :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
+                         :checked (contains? @*text-remove-options :page-ref)
+                         :on-change (fn [e]
+                                      (state/update-export-block-text-remove-options! e :page-ref)
+                                      (reset! *text-remove-options (state/get-export-block-text-remove-options))
+                                      (reset! *content (export-helper root-block-uuids-or-page-name)))})
+           [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
+            "[[text]] -> text"]
+
+           (ui/checkbox {:class "mr-2 ml-4"
+                         :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
+                         :checked (contains? @*text-remove-options :emphasis)
+                         :on-change (fn [e]
+                                      (state/update-export-block-text-remove-options! e :emphasis)
+                                      (reset! *text-remove-options (state/get-export-block-text-remove-options))
+                                      (reset! *content (export-helper root-block-uuids-or-page-name)))})
+
+           [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
+            "remove emphasis"]
+
+           (ui/checkbox {:class "mr-2 ml-4"
+                         :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
+                         :checked (contains? @*text-remove-options :tag)
+                         :on-change (fn [e]
+                                      (state/update-export-block-text-remove-options! e :tag)
+                                      (reset! *text-remove-options (state/get-export-block-text-remove-options))
+                                      (reset! *content (export-helper root-block-uuids-or-page-name)))})
+
+           [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
+            "remove #tags"]]
+
+          [:div.flex.items-center
+           (ui/checkbox {:class "mr-2"
+                         :style {:visibility (if (#{:text} tp) "visible" "hidden")}
+                         :checked (boolean (:newline-after-block @*text-other-options))
+                         :on-change (fn [e]
+                                      (state/update-export-block-text-other-options!
+                                       :newline-after-block (boolean (util/echecked? e)))
+                                      (reset! *text-other-options (state/get-export-block-text-other-options))
+                                      (reset! *content (export-helper root-block-uuids-or-page-name)))})
+           [:div {:style {:visibility (if (#{:text} tp) "visible" "hidden")}}
+            "newline after block"]
+
+           (ui/checkbox {:class "mr-2 ml-4"
+                         :style {:visibility (if (#{:text} tp) "visible" "hidden")}
+                         :checked (contains? @*text-remove-options :property)
+                         :on-change (fn [e]
+                                      (state/update-export-block-text-remove-options! e :property)
+                                      (reset! *text-remove-options (state/get-export-block-text-remove-options))
+                                      (reset! *content (export-helper root-block-uuids-or-page-name)))})
+           [:div {:style {:visibility (if (#{:text} tp) "visible" "hidden")}}
+            "remove properties"]]
+
+          [:div.flex.items-center
+           [:label.mr-2 {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
+            "level <="]
+           [:select.block.my-2.text-lg.rounded.border.px-2.py-0
+            {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
+             :value (or (:keep-only-level<=N @*text-other-options) :all)
+             :on-change (fn [e]
+                          (let [value (util/evalue e)
+                                level (if (= "all" value) :all (util/safe-parse-int value))]
+                            (state/update-export-block-text-other-options! :keep-only-level<=N level)
+                            (reset! *text-other-options (state/get-export-block-text-other-options))
+                            (reset! *content (export-helper root-block-uuids-or-page-name))))}
+            (for [n (cons "all" (range 1 10))]
+              [:option {:key n :value n} n])]]]))
+
+     (when @*content
+       [:div.mt-4
+        (ui/button (if @*copied? "Copied to clipboard!" "Copy to clipboard")
+                   :class "mr-4"
+                   :on-click (fn []
+                               (if (= tp :png)
+                                 (js/navigator.clipboard.write [(js/ClipboardItem. #js {"image/png" @*content})])
+                                 (util/copy-to-clipboard! @*content :html (when (= tp :html) @*content)))
+                               (reset! *copied? true)))
+        (ui/button "Save to file"
+                   :on-click #(let [file-name (if (string? root-block-uuids-or-page-name)
+                                                (-> (db/get-page root-block-uuids-or-page-name)
+                                                    (util/get-page-original-name))
+                                                (t/now))]
+                                (utils/saveToFile (js/Blob. [@*content]) (str "logseq_" file-name) (if (= tp :text) "txt" (name tp)))))])]))

+ 8 - 0
src/main/frontend/components/export.css

@@ -1,3 +1,11 @@
 .export textarea, .export select {
     background: var(--ls-primary-background-color);
 }
+
+#export-preview {
+  max-height: 50vh;
+  background-color: #fff;
+  background-image: linear-gradient(45deg, #808080 25%, transparent 25%), linear-gradient(-45deg, #808080 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #808080 75%), linear-gradient(-45deg, transparent 75%, #808080 75%);
+  background-size: 20px 20px;
+  background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
+}

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

@@ -1,5 +1,6 @@
 (ns frontend.components.page
-  (:require [clojure.string :as string]
+  (:require ["/frontend/utils" :as utils]
+            [clojure.string :as string]
             [frontend.components.block :as component-block]
             [frontend.components.query :as query]
             [frontend.components.content :as content]
@@ -638,6 +639,16 @@
                  "Clear All"]]
                [:a.opacity-70.opacity-100 {:on-click #(route-handler/go-to-search! :graph)}
                 "Click to search"])]))
+         {:search-filters search-graph-filters})
+        (graph-filter-section
+         [:span.font-medium "Export"]
+         (fn [open?]
+           (filter-expand-area
+            open?
+            (when-let [canvas (js/document.querySelector "#global-graph canvas")]
+              [:div.p-6
+               ;; We'll get an empty image if we don't wrap this in a requestAnimationFrame
+               [:div [:a {:on-click #(.requestAnimationFrame js/window (fn [] (utils/canvasToImage canvas "graph" "png")))} "as PNG"]]])))
          {:search-filters search-graph-filters})]]]]))
 
 (defonce last-node-position (atom nil))

+ 2 - 2
src/main/frontend/components/page_menu.cljs

@@ -134,11 +134,11 @@
                {:title   (t :page/open-with-default-app)
                 :options {:on-click #(js/window.apis.openPath file-fpath)}}]))
 
-          (when (state/get-current-page)
+          (when (or (state/get-current-page) whiteboard?)
             {:title   (t :export-page)
              :options {:on-click #(state/set-modal!
                                    (fn []
-                                     (export/export-blocks (:block/name page))))}})
+                                     (export/export-blocks (:block/name page) {:whiteboard? whiteboard?})))}})
 
           (when (util/electron?)
             {:title   (t (if public? :page/make-private :page/make-public))

+ 1 - 0
src/main/frontend/components/whiteboard.cljs

@@ -264,6 +264,7 @@
               :-webkit-font-smoothing "subpixel-antialiased"}}
 
      [:div.whiteboard-page-title-root
+      {:data-html2canvas-ignore true} ; excludes title component from image export
       [:div.whiteboard-page-title
        {:style {:color "var(--ls-primary-text-color)"
                 :user-select "none"}

+ 1 - 1
src/main/frontend/dicts.cljc

@@ -159,7 +159,7 @@
         :dev/show-block-data "(Dev) Show block data"
         :dev/show-block-ast "(Dev) Show block AST"
         :dev/show-page-ast "(Dev) Show page AST"
-        :content/copy-as "Copy as..."
+        :content/copy-export-as "Copy / Export as.."
         :content/copy-block-url "Copy block URL"
         :content/copy-block-ref "Copy block ref"
         :content/copy-block-emebed "Copy block embed"

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

@@ -2,6 +2,7 @@
   "Adapters related to tldraw"
   (:require ["/frontend/tldraw-logseq" :as TldrawLogseq]
             [frontend.components.block :as block]
+            [frontend.components.export :as export]
             [frontend.components.page :as page]
             [frontend.config :as config]
             [frontend.db.model :as model]
@@ -90,6 +91,7 @@
                        (clj->js
                         (model/query-block-by-uuid (parse-uuid block-uuid))))
    :getBlockPageName #(:block/name (model/get-block-page (state/get-current-repo) (parse-uuid %)))
+   :exportToImage (fn [page-name options] (state/set-modal! #(export/export-blocks page-name (merge (js->clj options :keywordize-keys true) {:whiteboard? true}))))
    :isWhiteboardPage model/whiteboard-page?
    :isMobile util/mobile?
    :saveAsset save-asset-handler

+ 19 - 0
src/main/frontend/utils.js

@@ -311,6 +311,25 @@ export const toPosixPath = (input) => {
   return input && input.replace(/\\+/g, '/')
 }
 
+export const saveToFile = (data, fileName, format) => {
+  if (!data) return
+  const url = URL.createObjectURL(data)
+  const link = document.createElement('a')
+  link.href = url
+  link.download = `${fileName}.${format}`
+  link.click()
+}
+
+export const canvasToImage = (canvas, title = 'Untitled', format = 'png') => {
+  canvas.toBlob(
+    (blob) => {
+      console.log(blob)
+      saveToFile(blob, title, format)
+    },
+    `image/.${format}`
+  )
+}
+
 export const nodePath = Object.assign({}, path, {
   basename (input) {
     input = toPosixPath(input)

+ 1 - 1
tldraw/apps/tldraw-logseq/src/components/ActionBar/ActionBar.tsx

@@ -29,7 +29,7 @@ export const ActionBar = observer(function ActionBar(): JSX.Element {
   }, [app])
 
   return (
-    <div className="tl-action-bar">
+    <div className="tl-action-bar" data-html2canvas-ignore="true">
       {!app.readOnly && (
         <div className="tl-toolbar tl-history-bar">
           <Button tooltip="Undo" onClick={undo}>

+ 1 - 0
tldraw/apps/tldraw-logseq/src/components/Button/CircleButton.tsx

@@ -28,6 +28,7 @@ export const CircleButton = ({
     <button
       data-active={active}
       data-recently-changed={recentlyChanged}
+      data-html2canvas-ignore="true"
       style={style}
       className="tl-circle-button"
       onPointerDown={onClick}

+ 24 - 2
tldraw/apps/tldraw-logseq/src/components/ContextMenu/ContextMenu.tsx

@@ -1,5 +1,6 @@
 import { useApp } from '@tldraw/react'
-import { MOD_KEY, AlignType, DistributeType, isDev } from '@tldraw/core'
+import { LogseqContext } from '../../lib/logseq-context'
+import { MOD_KEY, AlignType, DistributeType, isDev, EXPORT_PADDING } from '@tldraw/core'
 import { observer } from 'mobx-react-lite'
 import { TablerIcon } from '../icons'
 import { Button } from '../Button'
@@ -19,6 +20,7 @@ export const ContextMenu = observer(function ContextMenu({
   collisionRef,
 }: ContextMenuProps) {
   const app = useApp()
+  const { handlers } = React.useContext(LogseqContext)
   const rContent = React.useRef<HTMLDivElement>(null)
 
   const runAndTransition = (f: Function) => {
@@ -234,6 +236,27 @@ export const ContextMenu = observer(function ContextMenu({
             </ReactContextMenu.Item>
           )}
           <ReactContextMenu.Separator className="menu-separator" />
+          <ReactContextMenu.Item
+            className="tl-menu-item"
+            onClick={() =>
+              runAndTransition(() =>
+                handlers.exportToImage(app.currentPageId, {
+                  x: app.selectionBounds.minX + app.viewport.camera.point[0] - EXPORT_PADDING,
+                  y: app.selectionBounds.minY + app.viewport.camera.point[1] - EXPORT_PADDING,
+                  width: app.selectionBounds?.width + EXPORT_PADDING * 2,
+                  height: app.selectionBounds?.height + EXPORT_PADDING * 2,
+                  zoom: app.viewport.camera.zoom,
+                })
+              )
+            }
+          >
+            <TablerIcon className="tl-menu-icon" name="file-export" />
+            Export
+            <div className="tl-menu-right-slot">
+              <span className="keyboard-shortcut"></span>
+            </div>
+          </ReactContextMenu.Item>
+          <ReactContextMenu.Separator className="menu-separator" />
           <ReactContextMenu.Item
             className="tl-menu-item"
             onClick={() => runAndTransition(app.api.selectAll)}
@@ -311,7 +334,6 @@ export const ContextMenu = observer(function ContextMenu({
                       </span>
                     </div>
                   </ReactContextMenu.Item>
-                  )
                 </>
               )}
 

+ 1 - 1
tldraw/apps/tldraw-logseq/src/components/PrimaryTools/PrimaryTools.tsx

@@ -14,7 +14,7 @@ export const PrimaryTools = observer(function PrimaryTools() {
   }, [])
 
   return (
-    <div className="tl-primary-tools">
+    <div className="tl-primary-tools" data-html2canvas-ignore="true">
       <div className="tl-toolbar tl-tools-floating-panel">
         <ToolButton tooltip="Select" id="select" icon="select-cursor" />
         <ToolButton

+ 1 - 1
tldraw/apps/tldraw-logseq/src/components/StatusBar/StatusBar.tsx

@@ -7,7 +7,7 @@ import type { Shape } from '../../lib'
 export const StatusBar = observer(function StatusBar() {
   const app = useApp<Shape>()
   return (
-    <div className="tl-statusbar">
+    <div className="tl-statusbar" data-html2canvas-ignore="true">
       {app.selectedTool.id} | {app.selectedTool.currentState.id}
       <div style={{ flex: 1 }} />
       <div id="tl-statusbar-anchor" className="flex gap-1" />

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

@@ -44,6 +44,7 @@ export interface LogseqContextValue {
       filters: { 'pages?': boolean; 'blocks?': boolean; 'files?': boolean }
     ) => Promise<SearchResult>
     addNewWhiteboard: (pageName: string) => void
+    exportToImage: (pageName: string, options: object) => void
     addNewBlock: (content: string) => string // returns the new block uuid
     queryBlockByUUID: (uuid: string) => any
     getBlockPageName: (uuid: string) => string

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

@@ -62,6 +62,11 @@ const LogseqPortalShapeHeader = observer(
         ? getComputedColor(fill, 'background')
         : 'var(--ls-tertiary-background-color)'
 
+    const fillGradient =
+        fill && fill !== 'var(--ls-secondary-background-color)'
+          ? `var(--ls-highlight-color-${fill})`
+          : 'var(--ls-secondary-background-color)'
+
     return (
       <div
         className={`tl-logseq-portal-header tl-logseq-portal-header-${
@@ -72,7 +77,7 @@ const LogseqPortalShapeHeader = observer(
           className="absolute inset-0 tl-logseq-portal-header-bg"
           style={{
             opacity,
-            background: type === 'P' ? bgColor : `linear-gradient(0deg, transparent, ${bgColor}`,
+            background: type === 'P' ? bgColor : `linear-gradient(0deg, ${fillGradient}, ${bgColor})`,
           }}
         ></div>
         <div className="relative">{children}</div>

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

@@ -5,7 +5,8 @@ import { action, computed } from 'mobx'
 import { observer } from 'mobx-react-lite'
 import { withClampedStyles } from './style-props'
 
-export const YOUTUBE_REGEX = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/
+export const YOUTUBE_REGEX =
+  /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/
 
 export interface YouTubeShapeProps extends TLBoxShapeProps {
   type: 'youtube'
@@ -58,6 +59,7 @@ export class YouTubeShape extends TLBoxShape<YouTubeShapeProps> {
           style={{
             pointerEvents: isEditing ? 'all' : 'none',
             userSelect: 'none',
+            background: `url('https://img.youtube.com/vi/${this.embedId}/mqdefault.jpg') no-repeat center/cover`,
           }}
         >
           {this.embedId ? (

+ 1 - 4
tldraw/apps/tldraw-logseq/src/styles.css

@@ -91,6 +91,7 @@ html[data-theme='light'] {
 
 .tl-container {
   overflow: hidden;
+  background-color: transparent !important;
 }
 
 .tl-menu-item {
@@ -725,10 +726,6 @@ button.tl-select-input-trigger {
   user-select: text;
   transform-origin: top left;
 
-  &[data-collapsed='true'][data-editing='false'] {
-    @apply overflow-hidden;
-  }
-
   &[data-portal-selected='true'] {
     filter: brightness(0.9) contrast(0.5);
   }

+ 2 - 0
tldraw/packages/core/src/constants.ts

@@ -26,6 +26,8 @@ export const ZOOM_UPDATE_FACTOR = 0.8
 
 export const GRID_SIZE = 8
 
+export const EXPORT_PADDING = 8
+
 export const EMPTY_OBJECT: any = {}
 
 export const EMPTY_ARRAY: any[] = []

+ 10 - 2
tldraw/packages/react/src/components/Canvas/Canvas.tsx

@@ -129,7 +129,12 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
         {showGrid && components.Grid && <components.Grid size={gridSize} />}
         <HTMLLayer>
           {components.SelectionBackground && selectedShapes && selectionBounds && showSelection && (
-            <Container data-type="SelectionBackground" bounds={selectionBounds} zIndex={2}>
+            <Container
+              data-type="SelectionBackground"
+              bounds={selectionBounds}
+              zIndex={2}
+              data-html2canvas-ignore="true"
+            >
               <components.SelectionBackground
                 shapes={selectedShapes}
                 bounds={selectionBounds}
@@ -184,6 +189,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
               {showSelection && components.SelectionForeground && (
                 <Container
                   data-type="SelectionForeground"
+                  data-html2canvas-ignore="true"
                   bounds={selectionBounds}
                   zIndex={editingShape && selectedShapes.includes(editingShape) ? 1002 : 10002}
                 >
@@ -198,6 +204,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
               {showHandles && onlySelectedShapeWithHandles && components.Handle && (
                 <Container
                   data-type="onlySelectedShapeWithHandles"
+                  data-html2canvas-ignore="true"
                   bounds={selectionBounds}
                   zIndex={10003}
                 >
@@ -217,6 +224,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
               {selectedShapes && components.SelectionDetail && (
                 <SelectionDetailContainer
                   key={'detail' + selectedShapes.map(shape => shape.id).join('')}
+                  data-html2canvas-ignore="true"
                   shapes={selectedShapes}
                   bounds={selectionBounds}
                   detail={showSelectionRotation ? 'rotation' : 'size'}
@@ -235,7 +243,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
           />
         )}
 
-        <div id="tl-dev-tools-canvas-anchor" />
+        <div id="tl-dev-tools-canvas-anchor" data-html2canvas-ignore="true" />
       </div>
       <HTMLLayer>
         {selectedShapes && selectionBounds && (

+ 6 - 1
tldraw/packages/react/src/components/ContextBarContainer/ContextBarContainer.tsx

@@ -48,7 +48,12 @@ export const ContextBarContainer = observer(function ContextBarContainer<S exten
   }
 
   return (
-    <div ref={rBounds} className="tl-counter-scaled-positioned" aria-label="context-bar-container">
+    <div
+      ref={rBounds}
+      className="tl-counter-scaled-positioned"
+      aria-label="context-bar-container"
+      data-html2canvas-ignore="true"
+    >
       <ContextBar
         hidden={hidden}
         shapes={shapes}

+ 1 - 0
tldraw/packages/react/src/components/Indicator/Indicator.tsx

@@ -31,6 +31,7 @@ export const Indicator = observer(function Shape({
   return (
     <Container
       data-type="Indicator"
+      data-html2canvas-ignore="true"
       bounds={bounds}
       rotation={rotation}
       scale={scale}

+ 1 - 1
tldraw/packages/react/src/components/QuickLinksContainer/QuickLinksContainer.tsx

@@ -35,7 +35,7 @@ export const QuickLinksContainer = observer(function QuickLinksContainer<S exten
   const rounded = bounds.height * zoom < 50 || !app.selectedShapesArray.includes(shape)
 
   return (
-    <Container bounds={bounds} className="tl-quick-links-container">
+    <Container bounds={bounds} className="tl-quick-links-container" data-html2canvas-ignore="true">
       <HTMLContainer>
         <span
           style={{

+ 1 - 0
tldraw/packages/react/src/components/SelectionDetailContainer/SelectionDetailContainer.tsx

@@ -38,6 +38,7 @@ export const SelectionDetailContainer = observer(function SelectionDetail<S exte
       ref={rBounds}
       className={`tl-counter-scaled-positioned ${hidden ? `tl-fade-out` : ''}`}
       aria-label="bounds-detail-container"
+      data-html2canvas-ignore="true"
     >
       <SelectionDetail
         shapes={shapes}

+ 3 - 0
tldraw/packages/react/src/components/Shape/Shape.tsx

@@ -1,5 +1,6 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 import type { TLAsset } from '@tldraw/core'
+import { useApp } from '@tldraw/react'
 import { observer } from 'mobx-react-lite'
 import { useShapeEvents } from '../../hooks/useShapeEvents'
 import type { TLReactShape } from '../../lib'
@@ -35,10 +36,12 @@ export const Shape = observer(function Shape({
     props: { rotation, scale },
     ReactComponent,
   } = shape
+  const app = useApp<Shape>()
   const events = useShapeEvents(shape)
   return (
     <Container
       data-shape-id={shape.id}
+      data-html2canvas-ignore={(!isSelected && app.selectedShapes.size !== 0) || null}
       zIndex={zIndex}
       data-type="Shape"
       bounds={bounds}

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

@@ -34,7 +34,7 @@ export const DirectionIndicator = observer(function DirectionIndicator<
     }
   }, [direction, bounds])
   return (
-    <div ref={rIndicator} className="tl-direction-indicator">
+    <div ref={rIndicator} className="tl-direction-indicator" data-html2canvas-ignore="true">
       <svg height={12} width={12}>
         <polygon points="0,0 12,6 0,12" />
       </svg>

+ 6 - 1
tldraw/packages/react/src/components/ui/Grid/Grid.tsx

@@ -17,7 +17,12 @@ const SVGGrid = observer(function CanvasGrid({ size }: TLGridProps) {
     },
   } = useRendererContext()
   return (
-    <svg className="tl-grid" version="1.1" xmlns="http://www.w3.org/2000/svg">
+    <svg
+      className="tl-grid"
+      version="1.1"
+      xmlns="http://www.w3.org/2000/svg"
+      data-html2canvas-ignore="true"
+    >
       <defs>
         {STEPS.map(([min, mid, _size], i) => {
           const s = _size * size * zoom

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

@@ -10,7 +10,7 @@ export const SelectionBackground = observer(function SelectionBackground<S exten
   const events = useBoundsEvents('background')
 
   return (
-    <SVGContainer {...events}>
+    <SVGContainer data-html2canvas-ignore="true" {...events}>
       <rect
         className="tl-bounds-bg"
         width={Math.max(1, bounds.width)}

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