mirror of
https://github.com/logseq/logseq.git
synced 2026-04-24 14:14:55 +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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
20
resources/js/html2canvas.min.js
vendored
Normal file
20
resources/js/html2canvas.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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)))))])]))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user