mirror of
https://github.com/logseq/logseq.git
synced 2026-04-29 16:36:27 +00:00
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 <gabriel@logseq.com> Co-authored-by: Tienson Qin <tiensonqin@gmail.com>
This commit is contained in:
@@ -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"]
|
||||
(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))))))])
|
||||
|
||||
(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)))})
|
||||
(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")}}]]
|
||||
|
||||
[:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
|
||||
"remove emphasis"]
|
||||
[:textarea.overflow-y-auto.h-96 {:value @*content :read-only true}])
|
||||
|
||||
(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)))})
|
||||
(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"]
|
||||
|
||||
[:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
|
||||
"remove #tags"]]
|
||||
(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.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"]
|
||||
[:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
|
||||
"remove emphasis"]
|
||||
|
||||
(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"]]
|
||||
(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.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 {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
|
||||
"remove #tags"]]
|
||||
|
||||
[: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)))]]))
|
||||
[: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)))))])]))
|
||||
|
||||
Reference in New Issue
Block a user