diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index d1da43c97..ff24bed62 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -38,6 +38,11 @@ ;; --- FEATURES +(defn resolve-public-uri + [media-id] + (when media-id + (str (cf/get :public-uri) "/assets/by-id/" media-id))) + (def supported-features #{"storage/objects-map" "storage/pointer-map" @@ -413,15 +418,23 @@ f.modified_at, f.name, f.revn, - f.is_shared + f.is_shared, + ft.media_id from file as f + left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn) where f.project_id = ? and f.deleted_at is null order by f.modified_at desc") (defn get-project-files [conn project-id] - (db/exec! conn [sql:project-files project-id])) + (->> (db/exec! conn [sql:project-files project-id]) + (mapv (fn [row] + (if-let [media-id (:media-id row)] + (-> row + (dissoc :media-id) + (assoc :thumbnail-uri (resolve-public-uri media-id))) + (dissoc row :media-id)))))) (sv/defmethod ::get-project-files "Get all files for the specified project." @@ -668,9 +681,11 @@ f.modified_at, f.name, f.is_shared, + ft.media_id, row_number() over w as row_num from file as f - join project as p on (p.id = f.project_id) + inner join project as p on (p.id = f.project_id) + left join file_thumbnail as ft on (ft.file_id = f.id and ft.revn = f.revn) where p.team_id = ? and p.deleted_at is null and f.deleted_at is null @@ -681,7 +696,13 @@ (defn get-team-recent-files [conn team-id] - (db/exec! conn [sql:team-recent-files team-id])) + (->> (db/exec! conn [sql:team-recent-files team-id]) + (mapv (fn [row] + (if-let [media-id (:media-id row)] + (-> row + (dissoc :media-id) + (assoc :thumbnail-uri (resolve-public-uri media-id))) + (dissoc row :media-id)))))) (s/def ::get-team-recent-files (s/keys :req [::rpc/profile-id] diff --git a/backend/src/app/rpc/commands/files_thumbnails.clj b/backend/src/app/rpc/commands/files_thumbnails.clj index 8a1f5cb72..19233969e 100644 --- a/backend/src/app/rpc/commands/files_thumbnails.clj +++ b/backend/src/app/rpc/commands/files_thumbnails.clj @@ -14,7 +14,6 @@ [app.common.schema :as sm] [app.common.spec :as us] [app.common.types.shape-tree :as ctt] - [app.config :as cf] [app.db :as db] [app.db.sql :as sql] [app.loggers.audit :as-alias audit] @@ -39,10 +38,6 @@ ;; --- COMMAND QUERY: get-file-object-thumbnails -(defn- get-public-uri - [media-id] - (str (cf/get :public-uri) "/assets/by-id/" media-id)) - (defn- get-object-thumbnails ([conn file-id] (let [sql (str/concat @@ -52,7 +47,7 @@ res (db/exec! conn [sql file-id])] (->> res (d/index-by :object-id (fn [row] - (or (some-> row :media-id get-public-uri) + (or (some-> row :media-id files/resolve-public-uri) (:data row)))) (d/without-nils)))) @@ -65,7 +60,7 @@ res (db/exec! conn [sql file-id ids])] (d/index-by :object-id (fn [row] - (or (some-> row :media-id get-public-uri) + (or (some-> row :media-id files/resolve-public-uri) (:data row))) res)))) @@ -85,8 +80,6 @@ ;; --- COMMAND QUERY: get-file-thumbnail -;; FIXME: refactor to support uploading data to storage - (defn get-file-thumbnail [conn file-id revn] (let [sql (sql/select :file-thumbnail @@ -95,10 +88,15 @@ {:limit 1 :order-by [[:revn :desc]]}) row (db/exec-one! conn sql)] + (when-not row (ex/raise :type :not-found :code :file-thumbnail-not-found)) + (when-not (:data row) + (ex/raise :type :not-found + :code :file-thumbnail-not-found)) + {:data (:data row) :props (some-> (:props row) db/decode-transit-pgobject) :revn (:revn row) @@ -113,20 +111,16 @@ :opt-un [::revn])) (sv/defmethod ::get-file-thumbnail - "Method used in frontend for obtain the file thumbnail (used in the - dashboard)." - {::doc/added "1.17"} + {::doc/added "1.17" + ::doc/deprecated "1.19"} [{:keys [::db/pool]} {:keys [::rpc/profile-id file-id revn]}] (dm/with-open [conn (db/open pool)] (files/check-read-permissions! conn profile-id file-id) (-> (get-file-thumbnail conn file-id revn) (rph/with-http-cache long-cache-duration)))) - ;; --- COMMAND QUERY: get-file-data-for-thumbnail -;; FIXME: performance issue, handle new media_id -;; ;; We need to improve how we set frame for thumbnail in order to avoid ;; loading all pages into memory for find the frame set for thumbnail. @@ -427,24 +421,27 @@ :bucket "file-thumbnail"})] (db/exec-one! conn [sql:create-file-thumbnail file-id revn (:id media) props - (:id media) props]))) - -(s/def ::media ::media/upload) -(s/def ::create-file-thumbnail - (s/keys :req [::rpc/profile-id] - :req-un [::file-id ::revn ::props ::media])) + (:id media) props]) + media)) (sv/defmethod ::create-file-thumbnail "Creates or updates the file thumbnail. Mainly used for paint the grid thumbnails." {::doc/added "1.19" - ::audit/skip true} + ::audit/skip true + ::sm/params [:map {:title "create-file-thumbnail"} + [:file-id ::sm/uuid] + [:revn :int] + [:media ::media/upload]] + } + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}] (db/with-atomic [conn pool] (files/check-edition-permissions! conn profile-id file-id) (when-not (db/read-only? conn) - (-> cfg - (update ::sto/storage media/configure-assets-storage) - (assoc ::db/conn conn) - (create-file-thumbnail! params)) - nil))) + (let [media (-> cfg + (update ::sto/storage media/configure-assets-storage) + (assoc ::db/conn conn) + (create-file-thumbnail! params))] + + {:uri (files/resolve-public-uri (:id media))})))) diff --git a/backend/src/app/tasks/file_gc.clj b/backend/src/app/tasks/file_gc.clj index 9eceac13d..9b9c2134a 100644 --- a/backend/src/app/tasks/file_gc.clj +++ b/backend/src/app/tasks/file_gc.clj @@ -184,7 +184,7 @@ (when (seq res) (doseq [media-id (into #{} (keep :media-id) res)] ;; Mark as deleted the storage object related with the - ;; photo-id field. + ;; media-id field. (l/trace :hint "mark storage object as deleted" :id media-id) (sto/del-object! storage media-id)) diff --git a/backend/test/backend_tests/rpc_file_thumbnails_test.clj b/backend/test/backend_tests/rpc_file_thumbnails_test.clj index 84687d04f..14b0f72da 100644 --- a/backend/test/backend_tests/rpc_file_thumbnails_test.clj +++ b/backend/test/backend_tests/rpc_file_thumbnails_test.clj @@ -141,7 +141,7 @@ ))) -(t/deftest upsert-file-thumbnail +(t/deftest create-file-thumbnail (let [storage (::sto/storage th/*system*) profile (th/create-profile* 1) file (th/create-file* 1 {:profile-id (:id profile) @@ -159,7 +159,6 @@ data2 {::th/type :create-file-thumbnail ::rpc/profile-id (:id profile) :file-id (:id file) - :props {} :revn 2 :media {:filename "sample.jpg" :size 7923 @@ -169,7 +168,6 @@ data3 {::th/type :create-file-thumbnail ::rpc/profile-id (:id profile) :file-id (:id file) - :props {} :revn 3 :media {:filename "sample.jpg" :size 312043 @@ -183,11 +181,11 @@ (let [out (th/command! data2)] ;; (th/print-result! out) (t/is (nil? (:error out))) - (t/is (nil? (:result out)))) + (t/is (contains? (:result out) :uri))) (let [out (th/command! data3)] (t/is (nil? (:error out))) - (t/is (nil? (:result out)))) + (t/is (contains? (:result out) :uri))) (let [[row1 row2 row3 :as rows] (th/db-query :file-thumbnail {:file-id (:id file)} diff --git a/frontend/gulpfile.js b/frontend/gulpfile.js index f07eba915..2083b09df 100644 --- a/frontend/gulpfile.js +++ b/frontend/gulpfile.js @@ -131,7 +131,8 @@ function readManifest() { "polyfills": "js/polyfills.js", "main": "js/main.js", "shared": "js/shared.js", - "worker": "js/worker.js" + "worker": "js/worker.js", + "thumbnail-renderer": "js/thumbnail-renderer.js" }; } } @@ -242,7 +243,17 @@ gulp.task("template:render", templatePipeline({ output: paths.output })); -gulp.task("templates", gulp.series("svg:sprite:icons", "svg:sprite:cursors", "template:main", "template:render")); +gulp.task("template:thumbnail-renderer", templatePipeline({ + name: "thumbnail-renderer.html", + input: paths.resources + "templates/thumbnail-renderer.mustache", + output: paths.output +})); + +gulp.task("templates", gulp.series("svg:sprite:icons", + "svg:sprite:cursors", + "template:main", + "template:render", + "template:thumbnail-renderer")); gulp.task("polyfills", function() { return gulp.src(paths.resources + "polyfills/*.js") diff --git a/frontend/resources/styles/main/partials/dashboard-grid.scss b/frontend/resources/styles/main/partials/dashboard-grid.scss index d2c9b8fe2..2108b42d3 100644 --- a/frontend/resources/styles/main/partials/dashboard-grid.scss +++ b/frontend/resources/styles/main/partials/dashboard-grid.scss @@ -51,6 +51,10 @@ border-radius: $br3; border: 2px solid lighten($color-gray-20, 15%); text-align: initial; + + img { + object-fit: contain; + } } &.dragged { diff --git a/frontend/resources/templates/thumbnail-renderer.mustache b/frontend/resources/templates/thumbnail-renderer.mustache new file mode 100644 index 000000000..261cd05c0 --- /dev/null +++ b/frontend/resources/templates/thumbnail-renderer.mustache @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>Penpot - Thumbnail Renderer</title> + <link rel="icon" href="images/favicon.png" /> + + <script> + window.penpotVersion = "%version%"; + window.penpotBuildDate = "%buildDate%"; + </script> + + {{# manifest}} + <script>window.penpotWorkerURI="{{& worker}}"</script> + <script src="{{& config}}"></script> + <script src="{{& polyfills}}"></script> + {{/manifest}} + + </head> + <body> + {{# manifest}} + <script src="{{& shared}}"></script> + <script src="{{& thumbnail-renderer}}"></script> + {{/manifest}} + </body> +</html> diff --git a/frontend/shadow-cljs.edn b/frontend/shadow-cljs.edn index bb641ad0c..ea6faa935 100644 --- a/frontend/shadow-cljs.edn +++ b/frontend/shadow-cljs.edn @@ -16,17 +16,25 @@ :modules {:shared {:entries []} - :main {:entries [app.main] - :depends-on #{:shared} - :init-fn app.main/init} + :main + {:entries [app.main] + :depends-on #{:shared} + :init-fn app.main/init} - :render {:entries [app.render] - :depends-on #{:shared} - :init-fn app.render/init} + :render + {:entries [app.render] + :depends-on #{:shared} + :init-fn app.render/init} - :worker {:entries [app.worker] - :web-worker true - :depends-on #{:shared}}} + :worker + {:entries [app.worker] + :web-worker true + :depends-on #{:shared}} + + :thumbnail-renderer + {:entries [app.thumbnail-renderer] + :depends-on #{:shared} + :init-fn app.thumbnail-renderer/init}} :compiler-options {:output-feature-set :es2020 diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 4f5191ab0..72c22713b 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -84,7 +84,6 @@ (def default-theme "default") (def default-language "en") -(def worker-uri (obj/get global "penpotWorkerURI" "/js/worker.js")) (def translations (obj/get global "penpotTranslations")) (def themes (obj/get global "penpotThemes")) @@ -110,7 +109,14 @@ (def public-uri (atom (normalize-uri (or (obj/get global "penpotPublicURI") - (.-origin ^js location))))) + (obj/get location "origin"))))) + +(def thumbnail-renderer-uri + (or (some-> (obj/get global "penpotThumbnailRendererURI") normalize-uri) + (deref public-uri))) + +(def worker-uri + (obj/get global "penpotWorkerURI" "/js/worker.js")) ;; --- Helper Functions diff --git a/frontend/src/app/main.cljs b/frontend/src/app/main.cljs index 362ca0a58..ae4fa5fdd 100644 --- a/frontend/src/app/main.cljs +++ b/frontend/src/app/main.cljs @@ -15,6 +15,7 @@ [app.main.errors] [app.main.features :as feat] [app.main.store :as st] + [app.main.thumbnail-renderer :as tr] [app.main.ui :as ui] [app.main.ui.alert] [app.main.ui.confirm] @@ -80,6 +81,7 @@ (i18n/init! cf/translations) (theme/init! cf/themes) (cur/init-styles) + (tr/init!) (init-ui) (st/emit! (initialize))) diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index fdf4224cf..ce07c3289 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -782,6 +782,15 @@ (->> (rp/cmd! :set-file-shared params) (rx/ignore)))))) +(defn set-file-thumbnail + [file-id thumbnail-uri] + (ptk/reify ::set-file-thumbnail + ptk/UpdateEvent + (update [_ state] + (-> state + (d/update-in-when [:dashboard-files file-id] assoc :thumbnail-uri thumbnail-uri) + (d/update-in-when [:dashboard-recent-files file-id] assoc :thumbnail-uri thumbnail-uri))))) + ;; --- EVENT: create-file (declare file-created) diff --git a/frontend/src/app/main/fonts.cljs b/frontend/src/app/main/fonts.cljs index a9386ca2f..4a4dc8b6f 100644 --- a/frontend/src/app/main/fonts.cljs +++ b/frontend/src/app/main/fonts.cljs @@ -9,6 +9,7 @@ (:require-macros [app.main.fonts :refer [preload-gfonts]]) (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.logging :as log] [app.common.text :as txt] [app.config :as cf] @@ -148,15 +149,13 @@ ;; --- LOADER: CUSTOM -(def font-css-template +(def font-face-template "@font-face { font-family: '%(family)s'; font-style: %(style)s; font-weight: %(weight)s; font-display: block; - src: url(%(woff1-uri)s) format('woff'), - url(%(ttf-uri)s) format('ttf'), - url(%(otf-uri)s) format('otf'); + src: url(%(uri)s) format('woff'); }") (defn- asset-id->uri @@ -165,14 +164,11 @@ (defn generate-custom-font-variant-css [family variant] - (str/fmt font-css-template + (str/fmt font-face-template {:family family :style (:style variant) :weight (:weight variant) - :woff2-uri (asset-id->uri (::woff2-file-id variant)) - :woff1-uri (asset-id->uri (::woff1-file-id variant)) - :ttf-uri (asset-id->uri (::ttf-file-id variant)) - :otf-uri (asset-id->uri (::otf-file-id variant))})) + :uri (asset-id->uri (::woff1-file-id variant))})) (defn- generate-custom-font-css [{:keys [family variants] :as font}] @@ -237,26 +233,19 @@ (-> (obj/get-in js/document ["fonts" "ready"]) (p/then cb))) -(defn get-default-variant [{:keys [variants]}] - (or - (d/seek #(or (= (:id %) "regular") (= (:name %) "regular")) variants) - (first variants))) +(defn get-default-variant + [{:keys [variants]}] + (or (d/seek #(or (= (:id %) "regular") + (= (:name %) "regular")) variants) + (first variants))) + +(defn get-variant + [{:keys [variants] :as font} font-variant-id] + (or (d/seek #(= (:id %) font-variant-id) variants) + (get-default-variant font))) ;; Font embedding functions -;; Template for a CSS font face - -(def font-face-template " -/* latin */ -@font-face { - font-family: '%(family)s'; - font-style: %(style)s; - font-weight: %(weight)s; - font-display: block; - src: url(%(baseurl)sfonts/%(family)s-%(suffix)s.woff) format('woff'); -} -") - (defn get-content-fonts "Extracts the fonts used by the content of a text shape" [{font-id :font-id children :children :as content}] @@ -267,38 +256,52 @@ children-font (->> children (mapv get-content-fonts))] (reduce set/union (conj children-font current-font)))) - (defn fetch-font-css "Given a font and the variant-id, retrieves the fontface CSS" [{:keys [font-id font-variant-id] :or {font-variant-id "regular"}}] - (let [{:keys [backend family variants]} (get @fontsdb font-id)] + (let [{:keys [backend family] :as font} (get @fontsdb font-id)] (cond + (nil? font) + (rx/empty) + (= :google backend) - (let [variant (d/seek #(= (:id %) font-variant-id) variants)] + (let [variant (get-variant font font-variant-id)] (-> (generate-gfonts-url {:family family :variants [variant]}) (http/fetch-text))) (= :custom backend) - (let [variant (d/seek #(= (:id %) font-variant-id) variants) + (let [variant (get-variant font font-variant-id) result (generate-custom-font-variant-css family variant)] - (p/resolved result)) + (rx/of result)) :else - (let [{:keys [weight style suffix] :as variant} - (d/seek #(= (:id %) font-variant-id) variants) - font-data {:baseurl (str @cf/public-uri) - :family family - :style style - :suffix (or suffix font-variant-id) - :weight weight}] - (rx/of (str/fmt font-face-template font-data)))))) + (let [{:keys [weight style suffix]} (get-variant font font-variant-id) + suffix (or suffix font-variant-id) + params {:uri (dm/str @cf/public-uri "fonts/" family "-" suffix ".woff") + :family family + :style style + :weight weight}] + (rx/of (str/fmt font-face-template params)))))) (defn extract-fontface-urls "Parses the CSS and retrieves the font urls" [^string css] (->> (re-seq #"url\(([^)]+)\)" css) (mapv second))) + +(defn render-font-styles + [ids] + (->> (rx/from ids) + (rx/mapcat (fn [font-id] + (let [font (get @fontsdb font-id)] + (->> (:variants font []) + (map :id) + (map (fn [variant-id] + {:font-id font-id + :font-variant-id variant-id})))))) + (rx/mapcat fetch-font-css) + (rx/reduce (fn [acc css] (dm/str acc "\n" css)) ""))) diff --git a/frontend/src/app/main/repo.cljs b/frontend/src/app/main/repo.cljs index ca834ce20..2221eae29 100644 --- a/frontend/src/app/main/repo.cljs +++ b/frontend/src/app/main/repo.cljs @@ -50,6 +50,11 @@ :upsert-file-object-thumbnail {:query-params [:file-id :object-id]} :create-file-object-thumbnail {:query-params [:file-id :object-id] :form-data? true} + + :create-file-thumbnail + {:query-params [:file-id :revn] + :form-data? true} + :export-binfile {:response-type :blob} :import-binfile {:form-data? true} :retrieve-list-of-builtin-templates {:query-params :all} diff --git a/frontend/src/app/main/thumbnail_renderer.cljs b/frontend/src/app/main/thumbnail_renderer.cljs new file mode 100644 index 000000000..54c668af7 --- /dev/null +++ b/frontend/src/app/main/thumbnail_renderer.cljs @@ -0,0 +1,93 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.thumbnail-renderer + "A main entry point for the thumbnail renderer API interface. + + This ns is responsible to provide an API for create thumbnail + renderer iframes and interact with them using asyncrhonous + messages." + (:require + [app.common.data.macros :as dm] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.util.dom :as dom] + [beicon.core :as rx] + [cuerdas.core :as str])) + +(defonce ready? false) +(defonce queue #js []) +(defonce instance nil) +(defonce msgbus (rx/subject)) +(defonce origin + (dm/str (assoc cf/thumbnail-renderer-uri :path "/thumbnail-renderer.html"))) + +(declare send-message!) + +(defn- process-queued-messages! + [] + (loop [message (.shift ^js queue)] + (when (some? message) + (send-message! message) + (recur (.shift ^js queue))))) + +(defn- on-message + "Handles a message from the thumbnail renderer." + [event] + (let [evorigin (unchecked-get event "origin") + evdata (unchecked-get event "data")] + + (when (and (object? evdata) (str/starts-with? origin evorigin)) + (let [scope (unchecked-get evdata "scope") + type (unchecked-get evdata "type")] + (when (= "penpot/thumbnail-renderer" scope) + (when (= type "ready") + (set! ready? true) + (process-queued-messages!)) + (rx/push! msgbus evdata)))))) + +(defn- send-message! + "Sends a message to the thumbnail renderer." + [message] + (let [window (.-contentWindow ^js instance)] + (.postMessage ^js window message origin))) + +(defn- queue-message! + "Queues a message to be sent to the thumbnail renderer when it's ready." + [message] + (.push ^js queue message)) + +(defn render + "Renders a thumbnail." + [{:keys [data styles] :as params}] + (let [id (dm/str (uuid/next)) + payload #js {:data data :styles styles} + message #js {:id id + :scope "penpot/thumbnail-renderer" + :payload payload}] + + (if ^boolean ready? + (send-message! message) + (queue-message! message)) + + (->> msgbus + (rx/filter #(= id (unchecked-get % "id"))) + (rx/mapcat (fn [msg] + (case (unchecked-get msg "type") + "success" (rx/of (unchecked-get msg "payload")) + "failure" (rx/throw (unchecked-get msg "payload"))))) + (rx/take 1)))) + +(defn init! + "Initializes the thumbnail renderer." + [] + (let [iframe (dom/create-element "iframe")] + (dom/set-attribute! iframe "src" origin) + (dom/set-attribute! iframe "hidden" true) + (dom/append-child! js/document.body iframe) + + (set! instance iframe) + (.addEventListener js/window "message" on-message))) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 4384e97b5..27fc812ad 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -16,7 +16,9 @@ [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.render :refer [component-svg]] + [app.main.repo :as rp] [app.main.store :as st] + [app.main.thumbnail-renderer :as thr] [app.main.ui.components.color-bullet :as bc] [app.main.ui.dashboard.file-menu :refer [file-menu]] [app.main.ui.dashboard.import :refer [use-import-file]] @@ -30,7 +32,6 @@ [app.util.dom.dnd :as dnd] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] - [app.util.perf :as perf] [app.util.time :as dt] [app.util.timers :as ts] [beicon.core :as rx] @@ -41,44 +42,49 @@ ;; --- Grid Item Thumbnail -(defn ask-for-thumbnail +(defn- persist-thumbnail + [file-id revn blob] + (let [params {:file-id file-id :revn revn :media blob}] + (->> (rp/cmd! :create-file-thumbnail params) + (rx/map :uri)))) + +(defn- ask-for-thumbnail "Creates some hooks to handle the files thumbnails cache" - [file] + [file-id revn] (let [features (cond-> ffeat/enabled (features/active-feature? :components-v2) (conj "components/v2"))] - (wrk/ask! {:cmd :thumbnails/generate-for-file - :revn (:revn file) - :file-id (:id file) - :file-name (:name file) - :features features}))) + (->> (wrk/ask! {:cmd :thumbnails/generate-for-file + :revn revn + :file-id file-id + :features features}) + (rx/mapcat (fn [{:keys [fonts] :as result}] + (->> (fonts/render-font-styles fonts) + (rx/map (fn [styles] + (assoc result :styles styles)))))) + (rx/mapcat thr/render) + (rx/mapcat (partial persist-thumbnail file-id revn))))) (mf/defc grid-item-thumbnail - {::mf/wrap [mf/memo]} - [{:keys [file] :as props}] + {::mf/wrap-props false} + [{:keys [file-id revn thumbnail-uri background-color]}] (let [container (mf/use-ref) - bgcolor (dm/get-in file [:data :options :background]) visible? (h/use-visible container :once? true)] - (mf/with-effect [file visible?] - (when visible? - (let [tp (perf/tpoint)] - (->> (ask-for-thumbnail file) - (rx/subscribe-on :af) - (rx/subs (fn [{:keys [data fonts] :as params}] - (run! fonts/ensure-loaded! fonts) - (log/debug :hint "loaded thumbnail" - :file-id (dm/str (:id file)) - :file-name (:name file) - :elapsed (str/ffmt "%ms" (tp))) - (when-let [node (mf/ref-val container)] - (dom/set-html! node data)))))))) + (mf/with-effect [file-id revn visible? thumbnail-uri] + (when (and visible? (not thumbnail-uri)) + (->> (ask-for-thumbnail file-id revn) + (rx/subs (fn [url] + (st/emit! (dd/set-file-thumbnail file-id url))))))) [:div.grid-item-th - {:style {:background-color bgcolor} + {:style {:background-color background-color} :ref container} - i/loader-pencil])) + (when visible? + (if thumbnail-uri + [:img.grid-item-thumbnail-image {:src thumbnail-uri}] + i/loader-pencil))])) ;; --- Grid Item Library @@ -312,7 +318,12 @@ [:div.overlay] (if library-view? [:& grid-item-library {:file file}] - [:& grid-item-thumbnail {:file file}]) + [:& grid-item-thumbnail + {:file-id (:id file) + :revn (:revn file) + :thumbnail-uri (:thumbnail-uri file) + :background-color (dm/get-in file [:data :options :background])}]) + (when (and (:is-shared file) (not library-view?)) [:div.item-badge i/library]) [:div.info-wrapper diff --git a/frontend/src/app/thumbnail_renderer.cljs b/frontend/src/app/thumbnail_renderer.cljs new file mode 100644 index 000000000..ad05d322f --- /dev/null +++ b/frontend/src/app/thumbnail_renderer.cljs @@ -0,0 +1,245 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.thumbnail-renderer + "A main entry point for the thumbnail renderer process that is + executed on a separated iframe." + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.exceptions :as ex] + [app.common.logging :as log] + [app.config :as cf] + [app.util.dom :as dom] + [app.util.http :as http] + [app.util.object :as obj] + [app.util.webapi :as wapi] + [beicon.core :as rx] + [cuerdas.core :as str])) + +(log/set-level! :trace) + +(declare send-success!) +(declare send-failure!) + +(defonce parent-origin + (dm/str @cf/public-uri)) + +(defn- get-document-element + [^js svg] + (.-documentElement svg)) + +(defn- create-image + [uri] + (rx/create + (fn [subs] + (let [image (js/Image.)] + (obj/set! image "onload" #(do + (rx/push! subs image) + (rx/end! subs))) + + (obj/set! image "crossOrigin" "anonymous") + (obj/set! image "onerror" #(rx/error! subs %)) + (obj/set! image "onabort" #(rx/error! subs (ex/error :type :internal + :code :abort + :hint "operation aborted"))) + (obj/set! image "src" uri) + (fn [] + (obj/set! image "src" "") + (obj/set! image "onload" nil) + (obj/set! image "onerror" nil) + (obj/set! image "onabort" nil)))))) + +(defn- svg-get-size + [svg max] + (let [doc (get-document-element svg) + vbox (dom/get-attribute doc "viewBox")] + (when (string? vbox) + (let [[_ _ width height] (str/split vbox #"\s+") + width (d/parse-integer width 0) + height (d/parse-integer height 0) + ratio (/ width height)] + (if (> width height) + [max (* max (/ 1 ratio))] + [(* max ratio) max]))))) + +(defn- svg-has-intrinsic-size? + "Returns true if the SVG has an intrinsic size." + [svg] + (let [doc (get-document-element svg) + width (dom/get-attribute doc "width") + height (dom/get-attribute doc "height")] + (d/num? width height))) + +(defn- svg-set-intrinsic-size! + "Sets the intrinsic size of an SVG to the given max size." + [^js svg max] + (when-not (svg-has-intrinsic-size? svg) + (let [doc (get-document-element svg) + [w h] (svg-get-size svg max)] + (dom/set-attribute! doc "width" (dm/str w)) + (dom/set-attribute! doc "height" (dm/str h)))) + svg) + +(defn- fetch-as-data-uri + "Fetches a URL as a Data URI." + [uri] + (->> (http/send! {:uri uri + :response-type :blob + :method :get + :mode :cors + :omit-default-headers true}) + (rx/map :body) + (rx/mapcat wapi/read-file-as-data-url))) + +(defn- svg-update-image! + "Updates an image in an SVG to a Data URI." + [image] + (when-let [href (dom/get-attribute image "href")] + (->> (fetch-as-data-uri href) + (rx/map (fn [url] + (dom/set-attribute! image "href" url) + image))))) + +(defn- svg-resolve-images! + "Resolves all images in an SVG to Data URIs." + [svg] + (->> (rx/from (dom/query-all svg "image")) + (rx/mapcat svg-update-image!) + (rx/ignore))) + +(defn- svg-add-style! + "Adds a <style> node to an SVG." + [svg styles] + (let [doc (get-document-element svg) + style (dom/create-element svg "http://www.w3.org/2000/svg" "style")] + (dom/append-child! style (dom/create-text svg styles)) + (dom/append-child! doc style))) + +(defn- svg-resolve-styles! + "Resolves all fonts in an SVG to Data URIs." + [svg styles] + (->> (rx/from (re-seq #"url\((https?://[^)]+)\)" styles)) + (rx/map second) + (rx/mapcat (fn [url] + (->> (fetch-as-data-uri url) + (rx/map (fn [uri] [url uri]))))) + + (rx/reduce (fn [styles [url uri]] + (str/replace styles url uri)) + styles) + (rx/tap (partial svg-add-style! svg)) + (rx/ignore))) + +(defn- svg-resolve-all! + "Resolves all images and fonts in an SVG to Data URIs." + [svg styles] + (rx/concat + (svg-resolve-images! svg) + (svg-resolve-styles! svg styles) + (rx/of svg))) + +(defn- svg-parse + "Parses an SVG string into an SVG DOM." + [data] + (let [parser (js/DOMParser.)] + (.parseFromString ^js parser data "image/svg+xml"))) + +(defn- svg-stringify + "Converts an SVG to a string." + [svg] + (let [doc (get-document-element svg) + serializer (js/XMLSerializer.)] + (.serializeToString ^js serializer doc))) + +(defn- svg-prepare + "Prepares an SVG for rendering (resolves images to Data URIs and adds intrinsic size)." + [data styles] + (let [svg (svg-parse data)] + (->> (svg-resolve-all! svg styles) + (rx/map #(svg-set-intrinsic-size! % 300)) + (rx/map svg-stringify)))) + +(defn- bitmap->blob + "Converts an ImageBitmap to a Blob." + [bitmap] + (rx/create + (fn [subs] + (let [canvas (dom/create-element "canvas")] + (set! (.-width ^js canvas) (.-width ^js bitmap)) + (set! (.-height ^js canvas) (.-height ^js bitmap)) + (let [context (.getContext ^js canvas "bitmaprenderer")] + (.transferFromImageBitmap ^js context bitmap) + (.toBlob canvas #(do (rx/push! subs %) + (rx/end! subs)))) + + (constantly nil))))) + +(defn- render + "Renders a thumbnail using it's SVG and returns an ArrayBuffer of the image." + [payload] + (let [data (unchecked-get payload "data") + styles (unchecked-get payload "styles")] + (->> (svg-prepare data styles) + (rx/map #(wapi/create-blob % "image/svg+xml")) + (rx/map wapi/create-uri) + (rx/mapcat (fn [uri] + (->> (create-image uri) + (rx/mapcat wapi/create-image-bitmap) + (rx/tap #(wapi/revoke-uri uri))))) + (rx/mapcat bitmap->blob)))) + +(defn- on-message + "Handles messages from the main thread." + [event] + (let [evdata (unchecked-get event "data") + evorigin (unchecked-get event "origin")] + (when (str/starts-with? parent-origin evorigin) + (let [id (unchecked-get evdata "id") + payload (unchecked-get evdata "payload") + scope (unchecked-get evdata "scope")] + (when (and (some? payload) + (= scope "penpot/thumbnail-renderer")) + (->> (render payload) + (rx/subs (partial send-success! id) + (partial send-failure! id)))))))) + +(defn- listen + "Initializes the listener for messages from the main thread." + [] + (.addEventListener js/window "message" on-message)) + +(defn- send-answer! + "Sends an answer message." + [id type payload] + (let [message #js {:id id + :type type + :scope "penpot/thumbnail-renderer" + :payload payload}] + (when-not (identical? js/window js/parent) + (.postMessage js/parent message parent-origin)))) + +(defn- send-success! + "Sends a success message." + [id payload] + (send-answer! id "success" payload)) + +(defn- send-failure! + "Sends a failure message." + [id payload] + (send-answer! id "failure" payload)) + +(defn- send-ready! + "Sends a ready message." + [] + (send-answer! nil "ready" nil)) + +;; Initializes worker +(defn ^:export init + [] + (listen) + (send-ready!) + (log/info :hint "initialized" :public-uri @cf/public-uri)) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index e8c14dcc1..b78227180 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -254,7 +254,15 @@ ([tag] (.createElement globals/document tag)) ([ns tag] - (.createElementNS globals/document ns tag))) + (.createElementNS globals/document ns tag)) + ([document ns tag] + (.createElementNS document ns tag))) + +(defn create-text + ([^js text] + (create-text globals/document text)) + ([document ^js text] + (.createTextNode document text))) (defn set-html! [^js el html] diff --git a/frontend/src/app/util/webapi.cljs b/frontend/src/app/util/webapi.cljs index da5f8d30c..96ca465bc 100644 --- a/frontend/src/app/util/webapi.cljs +++ b/frontend/src/app/util/webapi.cljs @@ -130,6 +130,10 @@ (map #(.item file-list %)) (filter #(str/starts-with? (.-type %) "image/")))))) +(defn create-image-bitmap + [image] + (js/createImageBitmap image)) + (defn request-fullscreen [el] (cond diff --git a/frontend/src/app/worker/thumbnails.cljs b/frontend/src/app/worker/thumbnails.cljs index f17f4d85a..f489a0bcf 100644 --- a/frontend/src/app/worker/thumbnails.cljs +++ b/frontend/src/app/worker/thumbnails.cljs @@ -18,7 +18,6 @@ [app.util.webapi :as wapi] [app.worker.impl :as impl] [beicon.core :as rx] - [debug :refer [debug?]] [promesa.core :as p] [rumext.v2 :as mf])) @@ -45,15 +44,6 @@ :http-status status :http-body body}))) -(defn- not-found? - [{:keys [type]}] - (= :not-found type)) - -(defn- body-too-large? - [{:keys [type code]}] - (and (= :validation type) - (= :request-body-too-large code))) - (defn- request-data-for-thumbnail [file-id revn features] (let [path "api/rpc/command/get-file-data-for-thumbnail" @@ -69,70 +59,25 @@ (rx/map http/conditional-decode-transit) (rx/mapcat handle-response)))) -(defn- request-thumbnail - [file-id revn] - (let [path "api/rpc/command/get-file-thumbnail" - params {:file-id file-id - :revn revn} - request {:method :get - :uri (u/join @cf/public-uri path) - :credentials "include" - :query params}] - (->> (http/send! request) - (rx/map http/conditional-decode-transit) - (rx/mapcat handle-response)))) - (defn- render-thumbnail [{:keys [page file-id revn] :as params}] - (let [objects (:objects page) - frame (some->> page :thumbnail-frame-id (get objects)) - element (if frame - (mf/element render/frame-svg #js {:objects objects :frame frame :show-thumbnails? true}) - (mf/element render/page-svg #js {:data page :thumbnails? true})) - data (rds/renderToStaticMarkup element)] + (let [objects (:objects page) + frame (some->> page :thumbnail-frame-id (get objects)) + element (if frame + (mf/element render/frame-svg #js {:objects objects :frame frame :show-thumbnails? true}) + (mf/element render/page-svg #js {:data page :thumbnails? true :render-embed? true})) + data (rds/renderToStaticMarkup element) + font-ids (into @fonts/loaded (map first) @fonts/loading)] + {:data data - :fonts (into @fonts/loaded (map first) @fonts/loading) + :fonts font-ids :file-id file-id :revn revn})) -(defn- persist-thumbnail - [{:keys [file-id data revn fonts]}] - (let [path "api/rpc/command/upsert-file-thumbnail" - params {:file-id file-id - :revn revn - :props {:fonts fonts} - :data data} - request {:method :post - :uri (u/join @cf/public-uri path) - :credentials "include" - :body (http/transit-data params)}] - - (->> (http/send! request) - (rx/map http/conditional-decode-transit) - (rx/mapcat handle-response) - (rx/catch body-too-large? (constantly (rx/of nil))) - (rx/map (constantly params))))) - (defmethod impl/handler :thumbnails/generate-for-file [{:keys [file-id revn features] :as message} _] - (letfn [(on-result [{:keys [data props]}] - {:data data - :fonts (:fonts props)}) - - (on-cache-miss [_] - (log/debug :hint "request-thumbnail" :file-id file-id :revn revn :cache "miss") - (->> (request-data-for-thumbnail file-id revn features) - (rx/map render-thumbnail) - (rx/mapcat persist-thumbnail)))] - - (if (debug? :disable-thumbnail-cache) - (->> (request-data-for-thumbnail file-id revn features) - (rx/map render-thumbnail)) - (->> (request-thumbnail file-id revn) - (rx/tap (fn [_] - (log/debug :hint "request-thumbnail" :file-id file-id :revn revn :cache "hit"))) - (rx/catch not-found? on-cache-miss) - (rx/map on-result))))) + (->> (request-data-for-thumbnail file-id revn features) + (rx/map render-thumbnail))) (defmethod impl/handler :thumbnails/render-offscreen-canvas [_ ibpm]