From 748499a26fe86a19a016defe7ecd9ebdc2125f6d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 28 Sep 2022 09:40:14 +0200 Subject: [PATCH] :tada: Add lazy loading of thumbnails on dashboard --- backend/src/app/rpc/mutations/files.clj | 2 + frontend/src/app/main/ui/dashboard/grid.cljs | 153 +++++++------ .../src/app/main/ui/dashboard/projects.cljs | 210 ++++++++++-------- frontend/src/app/main/ui/hooks.cljs | 38 +++- frontend/src/app/util/perf.cljs | 12 + 5 files changed, 250 insertions(+), 165 deletions(-) diff --git a/backend/src/app/rpc/mutations/files.clj b/backend/src/app/rpc/mutations/files.clj index 839667c32..9c45d8798 100644 --- a/backend/src/app/rpc/mutations/files.clj +++ b/backend/src/app/rpc/mutations/files.clj @@ -565,6 +565,8 @@ (s/keys :req-un [::profile-id ::file-id ::revn ::data ::props])) (sv/defmethod ::upsert-file-thumbnail + "Creates or updates the file thumbnail. Mainly used for paint the + grid thumbnals." [{:keys [pool] :as cfg} {:keys [profile-id file-id revn data props]}] (db/with-atomic [conn pool] (files/check-edition-permissions! conn profile-id file-id) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 0d4e84a91..28536cf5e 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -6,9 +6,10 @@ (ns app.main.ui.dashboard.grid (:require + [app.common.data.macros :as dm] [app.common.logging :as log] [app.main.data.dashboard :as dd] - [app.main.data.messages :as dm] + [app.main.data.messages :as msg] [app.main.features :as features] [app.main.fonts :as fonts] [app.main.refs :as refs] @@ -19,43 +20,57 @@ [app.main.ui.dashboard.import :refer [use-import-file]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.dashboard.placeholder :refer [empty-placeholder loading-placeholder]] + [app.main.ui.hooks :as h] [app.main.ui.icons :as i] [app.main.worker :as wrk] [app.util.dom :as dom] [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] + [cuerdas.core :as str] [rumext.v2 :as mf])) -(log/set-level! :warn) +(log/set-level! :info) ;; --- Grid Item Thumbnail (defn ask-for-thumbnail "Creates some hooks to handle the files thumbnails cache" [file] - (let [components-v2 (features/active-feature? :components-v2)] - (wrk/ask! {:cmd :thumbnails/generate - :revn (:revn file) - :file-id (:id file) - :components-v2 components-v2}))) + (wrk/ask! {:cmd :thumbnails/generate + :revn (:revn file) + :file-id (:id file) + :file-name (:name file) + :components-v2 (features/active-feature? :components-v2)})) (mf/defc grid-item-thumbnail {::mf/wrap [mf/memo]} [{:keys [file] :as props}] - (let [container (mf/use-ref)] - (mf/with-effect [file] - (->> (ask-for-thumbnail file) - (rx/subs (fn [{:keys [data fonts] :as params}] - (run! fonts/ensure-loaded! fonts) - (when-let [node (mf/ref-val container)] - (dom/set-html! node data)))))) + (let [container (mf/use-ref) + bgcolor (dm/get-in file [:data :options :background]) + visible? (h/use-visible container :once? true)] - [:div.grid-item-th {:style {:background-color (get-in file [:data :options :background])} - :ref container} + (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/info :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)))))))) + + [:div.grid-item-th + {:style {:background-color bgcolor} + :ref container} i/loader-pencil])) ;; --- Grid Item Library @@ -144,6 +159,7 @@ (mf/defc grid-item-metadata [{:keys [modified-at]}] + (let [locale (mf/deref i18n/locale) time (dt/timeago modified-at {:locale locale})] [:span.date @@ -159,18 +175,19 @@ (mf/defc grid-item {:wrap [mf/memo]} [{:keys [file navigate? origin library-view?] :as props}] - (let [file-id (:id file) - local (mf/use-state {:menu-open false - :menu-pos nil - :edition false}) - selected-files (mf/deref refs/dashboard-selected-files) - dashboard-local (mf/deref refs/dashboard-local) - item-ref (mf/use-ref) - menu-ref (mf/use-ref) - selected? (contains? selected-files file-id) + (let [file-id (:id file) + local (mf/use-state {:menu-open false + :menu-pos nil + :edition false}) + selected-files (mf/deref refs/dashboard-selected-files) + dashboard-local (mf/deref refs/dashboard-local) + node-ref (mf/use-ref) + menu-ref (mf/use-ref) + + selected? (contains? selected-files file-id) on-menu-close - (mf/use-callback + (mf/use-fn #(swap! local assoc :menu-open false)) on-select @@ -184,7 +201,7 @@ (st/emit! (dd/toggle-file-select file))))) on-navigate - (mf/use-callback + (mf/use-fn (mf/deps file) (fn [event] (let [menu-icon (mf/ref-val menu-ref) @@ -193,14 +210,14 @@ (st/emit! (dd/go-to-workspace file)))))) on-drag-start - (mf/use-callback + (mf/use-fn (mf/deps selected-files) (fn [event] (let [offset (dom/get-offset-position (.-nativeEvent event)) select-current? (not (contains? selected-files (:id file))) - item-el (mf/ref-val item-ref) + item-el (mf/ref-val node-ref) counter-el (create-counter-element item-el (if select-current? 1 @@ -221,7 +238,7 @@ (ts/raf #(.removeChild ^js item-el counter-el))))) on-menu-click - (mf/use-callback + (mf/use-fn (mf/deps file selected?) (fn [event] (dom/prevent-default event) @@ -236,14 +253,14 @@ :menu-pos position)))) edit - (mf/use-callback + (mf/use-fn (mf/deps file) (fn [name] (st/emit! (dd/rename-file (assoc file :name name))) (swap! local assoc :edition false))) on-edit - (mf/use-callback + (mf/use-fn (mf/deps file) (fn [event] (dom/stop-propagation event) @@ -251,16 +268,14 @@ :edition true :menu-open false)))] - (mf/use-effect - (mf/deps selected? local) - (fn [] - (when (and (not selected?) (:menu-open @local)) - (swap! local assoc :menu-open false)))) + (mf/with-effect [selected? local] + (when (and (not selected?) (:menu-open @local)) + (swap! local assoc :menu-open false))) [:div.grid-item.project-th {:class (dom/classnames :selected selected? :library library-view?) - :ref item-ref + :ref node-ref :draggable true :on-click on-select :on-double-click on-navigate @@ -296,13 +311,15 @@ :origin origin :dashboard-local dashboard-local}])]]])) + (mf/defc grid [{:keys [files project on-create-clicked origin limit library-view?] :as props}] (let [dragging? (mf/use-state false) project-id (:id project) + node-ref (mf/use-var nil) on-finish-import - (mf/use-callback + (mf/use-fn (fn [] (st/emit! (dd/fetch-files {:project-id project-id}) (dd/fetch-shared-files) @@ -311,7 +328,7 @@ import-files (use-import-file project-id on-finish-import) on-drag-enter - (mf/use-callback + (mf/use-fn (fn [e] (when (or (dnd/has-type? e "Files") (dnd/has-type? e "application/x-moz-file")) @@ -319,32 +336,34 @@ (reset! dragging? true)))) on-drag-over - (mf/use-callback + (mf/use-fn (fn [e] (when (or (dnd/has-type? e "Files") (dnd/has-type? e "application/x-moz-file")) (dom/prevent-default e)))) on-drag-leave - (mf/use-callback + (mf/use-fn (fn [e] (when-not (dnd/from-child? e) (reset! dragging? false)))) - on-drop - (mf/use-callback + (mf/use-fn (fn [e] (when (or (dnd/has-type? e "Files") (dnd/has-type? e "application/x-moz-file")) (dom/prevent-default e) (reset! dragging? false) - (import-files (.-files (.-dataTransfer e))))))] + (import-files (.-files (.-dataTransfer e)))))) + ] - [:section.dashboard-grid {:on-drag-enter on-drag-enter - :on-drag-over on-drag-over - :on-drag-leave on-drag-leave - :on-drop on-drop} + [:section.dashboard-grid + {:on-drag-enter on-drag-enter + :on-drag-over on-drag-over + :on-drag-leave on-drag-leave + :on-drop on-drop + :ref node-ref} (cond (nil? files) [:& loading-placeholder] @@ -352,8 +371,10 @@ (seq files) [:div.grid-row {:style {:grid-template-columns (str "repeat(" limit ", 1fr)")}} + (when @dragging? [:div.grid-item]) + (for [item files] [:& grid-item {:file item @@ -361,21 +382,21 @@ :navigate? true :origin origin :library-view? library-view?}])] + :else - [:& empty-placeholder {:default? (:is-default project) - :on-create-clicked on-create-clicked - :project project - :limit limit - :origin origin}])])) + [:& empty-placeholder + {:default? (:is-default project) + :on-create-clicked on-create-clicked + :project project + :limit limit + :origin origin}])])) (mf/defc line-grid-row [{:keys [files selected-files dragging? limit] :as props}] - (let [limit (if dragging? - (dec limit) - limit)] - + (let [limit (if dragging? (dec limit) limit)] [:div.grid-row.no-wrap - {:style {:grid-template-columns (str "repeat(" limit ", 1fr)")}} + {:style {:grid-template-columns (dm/str "repeat(" limit ", 1fr)")}} + (when dragging? [:div.grid-item]) (for [item (take limit files)] @@ -396,8 +417,8 @@ selected-project (mf/deref refs/dashboard-selected-project) on-finish-import - (mf/use-callback - (mf/deps (:id team)) + (mf/use-fn + (mf/deps team-id) (fn [] (st/emit! (dd/fetch-recent-files (:id team)) (dd/clear-selected-files)))) @@ -405,7 +426,7 @@ import-files (use-import-file project-id on-finish-import) on-drag-enter - (mf/use-callback + (mf/use-fn (mf/deps selected-project) (fn [e] (when (dnd/has-type? e "penpot/files") @@ -421,7 +442,7 @@ (reset! dragging? true)))) on-drag-over - (mf/use-callback + (mf/use-fn (fn [e] (when (or (dnd/has-type? e "penpot/files") (dnd/has-type? e "Files") @@ -429,19 +450,19 @@ (dom/prevent-default e)))) on-drag-leave - (mf/use-callback + (mf/use-fn (fn [e] (when-not (dnd/from-child? e) (reset! dragging? false)))) on-drop-success (fn [] - (st/emit! (dm/success (tr "dashboard.success-move-file")) + (st/emit! (msg/success (tr "dashboard.success-move-file")) (dd/fetch-recent-files (:id team)) (dd/clear-selected-files))) on-drop - (mf/use-callback + (mf/use-fn (mf/deps files selected-files) (fn [e] (when (or (dnd/has-type? e "Files") diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 39e5d3f2f..08b507302 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -6,10 +6,11 @@ (ns app.main.ui.dashboard.projects (:require + [app.common.data :as d] [app.common.math :as mth] [app.main.data.dashboard :as dd] [app.main.data.events :as ev] - [app.main.data.messages :as dm] + [app.main.data.messages :as msg] [app.main.data.modal :as modal] [app.main.data.users :as du] [app.main.refs :as refs] @@ -43,8 +44,15 @@ (mf/defc team-hero {::mf/wrap [mf/memo]} [{:keys [team close-banner] :as props}] - (let [go-members #(st/emit! (dd/go-to-team-members)) - invite-member #(st/emit! (modal/show {:type :invite-members :team team :origin :hero}))] + (let [go-members (mf/use-fn #(st/emit! (dd/go-to-team-members))) + + invite-member + (mf/use-fn + (mf/deps team) + (fn [] + (st/emit! (modal/show {:type :invite-members + :team team + :origin :hero}))))] [:div.team-hero [:img {:src "images/deco-team-banner.png" :border "0"}] [:div.text @@ -52,7 +60,9 @@ [:div.info [:span (tr "dasboard.team-hero.text")] [:a {:on-click go-members} (tr "dasboard.team-hero.management")]]] - [:button.btn-primary.invite {:on-click invite-member} (tr "onboarding.choice.team-up.invite-members")] + [:button.btn-primary.invite + {:on-click invite-member} + (tr "onboarding.choice.team-up.invite-members")] [:button.close {:on-click close-banner} [:span i/close]]])) @@ -61,46 +71,47 @@ (mf/defc tutorial-project [{:keys [close-tutorial default-project-id] :as props}] - (let [state (mf/use-state - {:status :waiting - :file nil}) + (let [state (mf/use-state {:status :waiting + :file nil}) - template (->> (mf/deref builtin-templates) - (filter #(= (:id %) "tutorial-for-beginners")) - first) + templates (mf/deref builtin-templates) + template (d/seek #(= (:id %) "tutorial-for-beginners") templates) on-template-cloned-success - (mf/use-callback + (mf/use-fn + (mf/deps default-project-id) (fn [response] (swap! state #(assoc % :status :success :file (:first response))) (st/emit! (dd/go-to-workspace {:id (first response) :project-id default-project-id :name "tutorial"}) (du/update-profile-props {:viewed-tutorial? true})))) on-template-cloned-error - (fn [] - (swap! state #(assoc % :status :waiting)) - (st/emit! - (dm/error (tr "dashboard.libraries-and-templates.import-error")))) + (mf/use-fn + (fn [] + (swap! state #(assoc % :status :waiting)) + (st/emit! + (msg/error (tr "dashboard.libraries-and-templates.import-error"))))) download-tutorial - (fn [] - (let [mdata {:on-success on-template-cloned-success :on-error on-template-cloned-error} - params {:project-id default-project-id :template-id (:id template)}] - (swap! state #(assoc % :status :importing)) - (st/emit! (with-meta (dd/clone-template (with-meta params mdata)) - {::ev/origin "get-started-hero-block"}))))] + (mf/use-fn + (mf/deps template default-project-id) + (fn [] + (let [mdata {:on-success on-template-cloned-success :on-error on-template-cloned-error} + params {:project-id default-project-id :template-id (:id template)}] + (swap! state #(assoc % :status :importing)) + (st/emit! (with-meta (dd/clone-template (with-meta params mdata)) + {::ev/origin "get-started-hero-block"})))))] [:div.tutorial [:div.img] [:div.text [:div.title (tr "dasboard.tutorial-hero.title")] [:div.info (tr "dasboard.tutorial-hero.info")] - [:button.btn-primary.action {:on-click download-tutorial} + [:button.btn-primary.action {:on-click download-tutorial} (case (:status @state) :waiting (tr "dasboard.tutorial-hero.start") :importing [:span.loader i/loader-pencil] - :success "" - ) - ]] + :success "")]] + [:button.close {:on-click close-tutorial} [:span.icon i/close]]])) @@ -128,32 +139,32 @@ [{:keys [project first? team files] :as props}] (let [locale (mf/deref i18n/locale) file-count (or (:count project) 0) + project-id (:id project) dstate (mf/deref refs/dashboard-local) edit-id (:project-for-edit dstate) - local - (mf/use-state {:menu-open false - :menu-pos nil - :edition? (= (:id project) edit-id)}) + local (mf/use-state {:menu-open false + :menu-pos nil + :edition? (= (:id project) edit-id)}) + + width (mf/use-state nil) + rowref (mf/use-ref) + itemsize (if (>= @width 1030) + 280 + 230) + + ratio (if (some? @width) (/ @width itemsize) 0) + nitems (mth/floor ratio) + limit (min 10 nitems) + limit (max 1 limit) on-nav - (mf/use-callback + (mf/use-fn (mf/deps project) - #(st/emit! (rt/nav :dashboard-files {:team-id (:team-id project) - :project-id (:id project)}))) - - width (mf/use-state nil) - rowref (mf/use-ref) - itemsize (if (>= @width 1030) - 280 - 230) - - ratio (if (some? @width) (/ @width itemsize) 0) - nitems (mth/floor ratio) - limit (min 10 nitems) - limit (max 1 limit) - + (fn [] + (st/emit! (rt/nav :dashboard-files {:team-id (:team-id project) + :project-id project-id})))) toggle-pin (mf/use-callback (mf/deps project) @@ -209,22 +220,24 @@ (dd/fetch-recent-files (:id team)) (dd/clear-selected-files))))] - (mf/use-effect - (fn [] - (let [node (mf/ref-val rowref) - mnt? (volatile! true) - sub (->> (wapi/observe-resize node) - (rx/observe-on :af) - (rx/subs (fn [entries] - (let [row (first entries) - row-rect (.-contentRect ^js row) - row-width (.-width ^js row-rect)] - (when @mnt? - (reset! width row-width))))))] - (fn [] - (vreset! mnt? false) - (rx/dispose! sub))))) - [:div.dashboard-project-row {:class (when first? "first")} + (mf/with-effect + (let [node (mf/ref-val rowref) + mnt? (volatile! true) + sub (->> (wapi/observe-resize node) + (rx/observe-on :af) + (rx/subs (fn [entries] + (let [row (first entries) + row-rect (.-contentRect ^js row) + row-width (.-width ^js row-rect)] + (when @mnt? + (reset! width row-width))))))] + (fn [] + (vreset! mnt? false) + (rx/dispose! sub)))) + + + [:div.dashboard-project-row + {:class (when first? "first")} [:div.project {:ref rowref} [:div.project-name-wrapper (if (:edition? @local) @@ -265,6 +278,7 @@ [:a.btn-secondary.btn-small.tooltip.tooltip-bottom {:on-click on-menu-click :alt (tr "dashboard.options") :data-test "project-options"} i/actions]]] + (when (and (> limit 0) (> file-count limit)) [:div.show-more {:on-click on-nav} @@ -290,53 +304,53 @@ (reverse)) recent-map (mf/deref recent-files-ref) props (some-> profile (get :props {})) - team-hero? (:team-hero? props true) + team-hero? (and (:team-hero? props true) + (not (:is-default team))) tutorial-viewed? (:viewed-tutorial? props true) walkthrough-viewed? (:viewed-walkthrough? props true) - close-banner (fn [] - (st/emit! - (du/update-profile-props {:team-hero? false}) - (ptk/event ::ev/event {::ev/name "dont-show-team-up-hero" - ::ev/origin "dashboard"}))) + team-id (:id team) - close-tutorial (fn [] - (st/emit! - (du/update-profile-props {:viewed-tutorial? true}) - (ptk/event ::ev/event {::ev/name "dont-show" - ::ev/origin "get-started-hero-block" - :type "tutorial" - :section "dashboard"}))) + close-banner + (mf/use-fn + (fn [] + (st/emit! (du/update-profile-props {:team-hero? false}) + (ptk/event ::ev/event {::ev/name "dont-show-team-up-hero" + ::ev/origin "dashboard"})))) + close-tutorial + (mf/use-fn + (fn [] + (st/emit! (du/update-profile-props {:viewed-tutorial? true}) + (ptk/event ::ev/event {::ev/name "dont-show" + ::ev/origin "get-started-hero-block" + :type "tutorial" + :section "dashboard"})))) + close-walkthrough + (mf/use-fn + (fn [] + (st/emit! (du/update-profile-props {:viewed-walkthrough? true}) + (ptk/event ::ev/event {::ev/name "dont-show" + ::ev/origin "get-started-hero-block" + :type "walkthrough" + :section "dashboard"}))))] - close-walkthrough (fn [] - (st/emit! - (du/update-profile-props {:viewed-walkthrough? true}) - (ptk/event ::ev/event {::ev/name "dont-show" - ::ev/origin "get-started-hero-block" - :type "walkthrough" - :section "dashboard"})))] + (mf/with-effect [team] + (let [tname (if (:is-default team) + (tr "dashboard.your-penpot") + (:name team))] + (dom/set-html-title (tr "title.dashboard.projects" tname)))) - (mf/use-effect - (mf/deps team) - (fn [] - (let [tname (if (:is-default team) - (tr "dashboard.your-penpot") - (:name team))] - (dom/set-html-title (tr "title.dashboard.projects" tname))))) - - (mf/use-effect - (mf/deps (:id team)) - (fn [] - (st/emit! (dd/fetch-recent-files (:id team)) - (dd/clear-selected-files)))) + (mf/with-effect [team-id] + (st/emit! (dd/fetch-recent-files team-id) + (dd/clear-selected-files))) (when (seq projects) [:* [:& header] - (when (and team-hero? (not (:is-default team))) - [:& team-hero - {:team team - :close-banner close-banner}]) + + (when team-hero? + [:& team-hero {:team team :close-banner close-banner}]) + (when (or (not tutorial-viewed?) (not walkthrough-viewed?)) [:div.hero-projects (when (and (not tutorial-viewed?) (:is-default team)) @@ -358,5 +372,5 @@ :team team :files files :first? (= project (first projects)) - :key (:id project)}]))]]))) + :key id}]))]]))) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index c8e61a442..c0afc15dd 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -29,7 +29,7 @@ (defn use-rxsub [ob] - (let [[state reset-state!] (mf/useState @ob)] + (let [[state reset-state!] (mf/useState #(if (satisfies? IDeref ob) @ob nil))] (mf/useEffect (fn [] (let [sub (rx/subscribe ob #(reset-state! %))] @@ -313,3 +313,39 @@ (use-stream stream (partial reset! state)) state)) + +(defonce ^:private intersection-subject (rx/subject)) +(defonce ^:private intersection-observer + (delay (js/IntersectionObserver. + (fn [entries _] + (run! (partial rx/push! intersection-subject) (seq entries))) + #js {:rootMargin "0px" + :threshold 1.0}))) + +(defn use-visible + [ref & {:keys [once?]}] + (let [[state update-state!] (mf/useState false)] + (mf/with-effect [once?] + (let [node (mf/ref-val ref) + stream (->> intersection-subject + (rx/filter (fn [entry] + (let [target (unchecked-get entry "target")] + (identical? target node)))) + (rx/map (fn [entry] + (let [ratio (unchecked-get entry "intersectionRatio") + intersecting? (unchecked-get entry "isIntersecting")] + (or intersecting? (> ratio 0.5))))) + (rx/dedupe)) + stream (if once? + (->> stream + (rx/filter identity) + (rx/take 1)) + stream) + subs (rx/subscribe stream update-state!)] + (.observe ^js @intersection-observer node) + (fn [] + (.unobserve ^js @intersection-observer node) + (rx/dispose! subs)))) + + state)) + diff --git a/frontend/src/app/util/perf.cljs b/frontend/src/app/util/perf.cljs index d6765564e..f962e993e 100644 --- a/frontend/src/app/util/perf.cljs +++ b/frontend/src/app/util/perf.cljs @@ -131,3 +131,15 @@ (js/performance.clearMeasures end-mark) #js {:duration duration :avg avg}))) + +(defn now + [] + (js/performance.now)) + +(defn tpoint + "Create a measurement checkpoint for time measurement of potentially + asynchronous flow." + [] + (let [p1 (now)] + #(js/Math.floor (- (now) p1)))) +