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:
Konstantinos
2023-04-12 12:39:22 +03:00
committed by GitHub
parent 339fb7ceb2
commit 95149e13f6
34 changed files with 343 additions and 141 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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)))))])]))

View File

@@ -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;
}

View File

@@ -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))

View File

@@ -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))

View File

@@ -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"}

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

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

View File

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

View File

@@ -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>
)
</>
)}

View File

@@ -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

View File

@@ -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" />

View File

@@ -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

View File

@@ -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>

View File

@@ -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 ? (

View File

@@ -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);
}

View File

@@ -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[] = []

View File

@@ -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 && (

View File

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

View File

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

View File

@@ -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={{

View File

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

View File

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

View File

@@ -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>

View File

@@ -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

View File

@@ -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)}