;; 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 Sucursal en España SL (ns app.main.ui.dashboard.grid (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] [app.common.data.macros :as dm] [app.common.geom.point :as gpt] [app.common.logging :as log] [app.common.time :as ct] [app.config :as cf] [app.main.data.common :as dcm] [app.main.data.dashboard :as dd] [app.main.data.notifications :as ntf] [app.main.data.project :as dpj] [app.main.data.team :as dtm] [app.main.features :as features] [app.main.fonts :as fonts] [app.main.rasterizer :as thr] [app.main.refs :as refs] [app.main.render :as render] [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.color-bullet :as bc] [app.main.ui.components.portal :refer [portal-on-document*]] [app.main.ui.dashboard.file-menu :refer [file-menu*]] [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-grid-placeholder* loading-placeholder*]] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.product.loader :refer [loader*]] [app.main.ui.hooks :as h] [app.main.worker :as mw] [app.util.color :as uc] [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.timers :as ts] [beicon.v2.core :as rx] [cuerdas.core :as str] [rumext.v2 :as mf])) (log/set-level! :debug) (def thumbnail-width 252) ;; --- Grid Item 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 :id)))) (defn render-thumbnail [file-id revn] (if (features/active-feature? @st/state "render-wasm/v1") (mw/ask! {:cmd :thumbnails/generate-for-file-wasm :revn revn :file-id file-id :width thumbnail-width}) (->> (mw/ask! {:cmd :thumbnails/generate-for-file :revn revn :file-id file-id :width thumbnail-width}) (rx/mapcat (fn [{:keys [fonts] :as result}] (->> (fonts/render-font-styles fonts) (rx/map (fn [styles] (-> result (assoc :styles styles :width thumbnail-width)))))))))) (defn- ask-for-thumbnail "Creates some hooks to handle the files thumbnails cache" [file-id revn] (->> (render-thumbnail file-id revn) (rx/mapcat thr/render) (rx/mapcat (partial persist-thumbnail file-id revn)))) (mf/defc grid-item-thumbnail* {::mf/private true} [{:keys [can-edit file can-restore]}] (let [file-id (get file :id) revn (get file :revn) thumbnail-id (get file :thumbnail-id) bg-color (dm/get-in file [:data :background]) container (mf/use-ref) visible? (h/use-visible container :once? true)] (mf/with-effect [file-id revn visible? thumbnail-id] (when (and can-edit visible? (not thumbnail-id)) (let [subscription (->> (ask-for-thumbnail file-id revn) (rx/subs! (fn [thumbnail-id] (st/emit! (dd/set-file-thumbnail file-id thumbnail-id))) (fn [cause] (log/error :hint "unable to render thumbnail" :file-if file-id :revn revn :message (ex-message cause)))))] (partial rx/dispose! subscription)))) [:div {:class (stl/css-case :grid-item-thumbnail true :is-deleted can-restore) :style {:background-color bg-color} :ref container} (when visible? (if thumbnail-id [:img {:class (stl/css :grid-item-thumbnail-image) :draggable (dm/str can-edit) :src (cf/resolve-media thumbnail-id) :loading "lazy" :decoding "async"}] (when can-edit [:> loader* {:class (stl/css :grid-loader) :draggable (dm/str can-edit) :overlay true :title (tr "labels.loading")}])))])) ;; --- Grid Item Library (mf/defc grid-item-library* [{:keys [file can-restore]}] (mf/with-effect [file] (when file (let [font-ids (map :font-id (get-in file [:library-summary :typographies :sample] []))] (run! fonts/ensure-loaded! font-ids)))) [:div {:class (stl/css-case :library-thumbnail true :is-deleted can-restore)} (if (nil? file) [:> loader* {:class (stl/css :grid-loader) :overlay true :title (tr "labels.loading")}] (let [summary (:library-summary file) components (:components summary) colors (:colors summary) typographies (:typographies summary)] [:* (when (and (zero? (:count components)) (zero? (:count colors)) (zero? (:count typographies))) [:* [:div {:class (stl/css :library-asset-section)} [:div {:class (stl/css :library-asset-title)} [:span (tr "workspace.assets.components")] [:span {:class (stl/css :library-num-assets)} (str "\u00A0(") 0 ")"]]] ;; Unicode 00A0 is non-breaking space [:div {:class (stl/css :library-asset-section)} [:div {:class (stl/css :library-asset-title)} [:span (tr "workspace.assets.colors")] [:span {:class (stl/css :library-num-assets)} (str "\u00A0(") 0 ")"]]] ;; Unicode 00A0 is non-breaking space [:div {:class (stl/css :library-asset-section)} [:div {:class (stl/css :library-asset-title)} [:span (tr "workspace.assets.typography")] [:span {:class (stl/css :library-num-assets)} (str "\u00A0(") 0 ")"]]]]) ;; Unicode 00A0 is non-breaking space (when (pos? (:count components)) [:div {:class (stl/css :library-asset-section)} [:div {:class (stl/css :library-asset-title)} [:span (tr "workspace.assets.components")] [:span {:class (stl/css :library-num-assets)} (str "\u00A0(") (:count components) ")"]] ;; Unicode 00A0 is non-breaking space [:div {:class (stl/css :library-asset-list)} (for [component (:sample components)] (let [root-id (:main-instance-id component)] [:div {:class (stl/css :library-asset-item) :key (str "assets-component-" (:id component))} [:& render/component-svg {:class (stl/css :library-asset-icon) :root-shape (get-in component [:objects root-id]) :objects (:objects component)}] ;; Components in the summary come loaded with objects, even in v2 [:div {:class (stl/css :library-name-block)} [:span {:class (stl/css :library-item-name) :title (:name component)} (:name component)]]])) (when (> (:count components) (count (:sample components))) [:div {:class (stl/css :library-asset-item)} [:div {:class (stl/css :library-name-block)} [:span {:class (stl/css :library-item-name)} "(...)"]]])]]) (when (pos? (:count colors)) [:div {:class (stl/css :library-asset-section)} [:div {:class (stl/css :library-asset-title)} [:span (tr "workspace.assets.colors")] [:span {:class (stl/css :library-num-assets)} (str "\u00A0(") (:count colors) ")"]] ;; Unicode 00A0 is non-breaking space [:div {:class (stl/css :library-asset-list)} (for [color (:sample colors)] (let [default-name (cond (:gradient color) (uc/gradient-type->string (get-in color [:gradient :type])) (:color color) (:color color) :else (:value color))] [:div {:class (stl/css :library-asset-item :library-color-item) :key (str "assets-color-" (:id color))} [:> bc/color-bullet* {:color {:color (:color color) :id (:id color) :opacity (:opacity color)} :mini true}] [:div {:class (stl/css :library-name-block)} [:span {:class (stl/css :library-color-name)} (:name color)] (when-not (= (:name color) default-name) [:span {:class (stl/css :library-color-value)} (:color color)])]])) (when (> (:count colors) (count (:sample colors))) [:div {:class (stl/css :library-asset-item)} [:div {:class (stl/css :library-name-block)} [:span {:class (stl/css :library-item-name)} "(...)"]]])]]) (when (pos? (:count typographies)) [:div {:class (stl/css :library-asset-section)} [:div {:class (stl/css :library-asset-title)} [:span (tr "workspace.assets.typography")] [:span {:class (stl/css :library-num-assets)} (str "\u00A0(") (:count typographies) ")"]] ;; Unicode 00A0 is non-breaking space [:div {:class (stl/css :library-asset-list)} (for [typography (:sample typographies)] [:div {:class (stl/css :library-asset-item) :key (str "assets-typography-" (:id typography))} [:div {:class (stl/css :library-typography-sample) :style {:font-family (:font-family typography) :font-weight (:font-weight typography) :font-style (:font-style typography)}} (tr "workspace.assets.typography.sample")] [:div {:class (stl/css :library-name-block)} [:span {:class (stl/css :library-item-name) :title (:name typography)} (:name typography)]]]) (when (> (:count typographies) (count (:sample typographies))) [:div {:class (stl/css :library-asset-item)} [:div {:class (stl/css :library-name-block)} [:span {:class (stl/css :library-item-name)} "(...)"]]])]])]))]) ;; --- Grid Item (mf/defc grid-item-metadata* {::mf/private true} [{:keys [file layout]}] (let [deleted-at (:will-be-deleted-at file) date-class (stl/css-case :grid-item-date (= layout :grid) :list-item-date (= layout :list))] (if deleted-at (let [time (ct/timeago deleted-at)] [:span {:class date-class :title (tr "dashboard.deleted.will-be-deleted-at" time)} time]) (let [time (ct/timeago (:modified-at file))] [:span {:class date-class :title (tr "dashboard.grid.last-modified-at" time)} time])))) (defn create-counter-element [_element file-count] (let [counter-el (dom/create-element "div")] (dom/set-property! counter-el "class" (stl/css :drag-counter)) (dom/set-text! counter-el (str file-count)) counter-el)) (mf/defc grid-item* {::mf/private true} [{:keys [file origin can-edit selected-files can-restore layout]}] (let [node-ref (mf/use-ref) menu-ref (mf/use-ref) state (mf/deref refs/dashboard-local) file-id (get file :id) menu-pos (get state :menu-pos) menu-open? (and (get state :menu-open) (= file-id (:file-id state))) selected? (contains? selected-files file-id) selected-num (count selected-files) list? (= layout :list) editing? (and (= file-id (:file-id state)) (:edition state)) library-view? (= origin :libraries) on-menu-close (mf/use-fn #(st/emit! (dd/hide-file-menu))) on-select (mf/use-fn (mf/deps selected? selected-num) (fn [event] (when (or (not selected?) (> selected-num 1)) (dom/stop-propagation event) (let [shift? (kbd/shift? event)] (when-not shift? (st/emit! (dd/clear-selected-files))) (st/emit! (dd/toggle-file-select file)))))) on-navigate (mf/use-fn (mf/deps file-id can-restore) (fn [event] (when-not can-restore (let [menu-icon (mf/ref-val menu-ref) target (dom/get-target event)] (when-not (dom/child? target menu-icon) (st/emit! (dcm/go-to-workspace :file-id file-id))))))) on-drag-start (mf/use-fn (mf/deps selected? selected-num) (fn [event] (st/emit! (dd/hide-file-menu)) (when can-edit (let [offset (dom/get-offset-position (dom/event->native-event event)) item-el (mf/ref-val node-ref) counter-el (create-counter-element item-el (if (not selected?) 1 selected-num))] (when (not selected?) (st/emit! (dd/clear-selected-files)) (st/emit! (dd/toggle-file-select file))) (dnd/set-data! event "penpot/files" "dummy") (dnd/set-allowed-effect! event "move") ;; set-drag-image requires that the element is rendered ;; and visible to the user at the moment of creating the ;; ghost image (to make a snapshot), but you may remove ;; it right afterwards, in the next render cycle. (dom/append-child! item-el counter-el) (dnd/set-drag-image! event item-el (:x offset) (:y offset)) ;; Guard against race condition: if the user navigates away ;; before the RAF fires, item-el may have been unmounted and ;; counter-el is no longer a child — removeChild would throw. (ts/raf #(when (dom/child? counter-el item-el) (dom/remove-child! item-el counter-el))))))) on-menu-click (mf/use-fn (mf/deps file selected? menu-open?) (fn [event] (dom/stop-propagation event) (if menu-open? (st/emit! (dd/hide-file-menu)) (do (when-not selected? (when-not (kbd/shift? event) (st/emit! (dd/clear-selected-files))) (st/emit! (dd/toggle-file-select file))) (let [client-position (dom/get-client-position event) position (if (and (nil? (:y client-position)) (nil? (:x client-position))) (let [target-element (dom/get-target event) points (dom/get-bounding-rect target-element) y (:top points) x (:left points)] (gpt/point x y)) client-position)] (st/emit! (dd/show-file-menu-with-position file-id position))))))) on-context-menu (mf/use-fn (mf/deps on-menu-click) (fn [event] (dom/prevent-default event) (on-menu-click event))) edit (mf/use-fn (mf/deps file) (fn [name] (let [name (str/trim name)] (when (not= name "") (st/emit! (dd/rename-file (assoc file :name name))))) (st/emit! (dd/stop-edit-file-name)))) on-edit (mf/use-fn (mf/deps file) (fn [event] (dom/stop-propagation event) (st/emit! (dd/start-edit-file-name file-id)))) on-key-down (mf/use-fn (mf/deps on-navigate on-select) (fn [event] (dom/stop-propagation event) (when (kbd/enter? event) (on-navigate event)) (when (kbd/shift? event) (when (or (kbd/down-arrow? event) (kbd/left-arrow? event) (kbd/up-arrow? event) (kbd/right-arrow? event)) ;; TODO Fix this (on-select event))))) on-menu-key-down (mf/use-fn (mf/deps on-menu-click) (fn [event] (when (kbd/enter? event) (dom/stop-propagation event) (dom/prevent-default event) (on-menu-click event)))) ;; The options menu is identical in both layouts, so we build it once ;; and place it where each layout needs it. NOTE: hiccup bound in a ;; let is not compiled by rumext, so it must be wrapped in mf/html. menu-element (mf/html [:div {:class (stl/css-case :project-thumbnail-actions true :is-force-display menu-open?)} [:div {:class (stl/css :project-thumbnail-icon :menu) :tab-index "0" :role "button" :aria-label (tr "dashboard.options") :ref menu-ref :id (dm/str file-id "-action-menu") :on-click on-menu-click :on-key-down on-menu-key-down} [:> icon* {:icon-id i/menu :class (stl/css :menu-icon)}] (when (and selected? menu-open?) ;; When the menu is open we disable events in the dashboard. We need to force pointer events ;; so the menu can be handled [:> portal-on-document* {} [:> file-menu* {:files (vals selected-files) :left (+ 24 (:x menu-pos)) :top (:y menu-pos) :can-edit can-edit :navigate true :on-edit on-edit :on-close on-menu-close :origin origin :parent-id (dm/str file-id "-action-menu") :can-restore can-restore}]])]])] (if ^boolean list? [:li {:class (stl/css-case :grid-item true :list-item true :library-item library-view?)} [:div {:class (stl/css-case :list-item-row true :is-selected selected?) :ref node-ref :role "button" :title (:name file) :aria-label (:name file) :draggable (dm/str can-edit) :on-click on-select :on-key-down on-key-down :on-double-click on-navigate :on-drag-start on-drag-start :on-context-menu on-context-menu} (if ^boolean editing? [:& inline-edition {:content (:name file) :on-end edit :max-length 250}] [:h3 {:class (stl/css :list-item-name)} (:name file)]) (when (and (:is-shared file) (not library-view?)) [:span {:class (stl/css :list-item-badge) :aria-label (tr "workspace.assets.shared-library") :title (tr "workspace.assets.shared-library")} [:> icon* {:icon-id i/library}]]) [:> grid-item-metadata* {:file file :layout :list}] menu-element]] [:li {:class (stl/css-case :grid-item true :project-thumbnail true :library-item library-view?)} [:div {:class (stl/css-case :is-selected selected? :grid-item-button true) :ref node-ref :role "button" :title (:name file) :aria-label (:name file) :draggable (dm/str can-edit) :on-click on-select :on-key-down on-key-down :on-double-click on-navigate :on-drag-start on-drag-start :on-context-menu on-context-menu} (if ^boolean library-view? [:> grid-item-library* {:file file :can-restore can-restore}] [:> grid-item-thumbnail* {:file file :can-edit can-edit :can-restore can-restore}]) (when (and (:is-shared file) (not library-view?)) [:div {:class (stl/css :grid-item-badge)} [:> icon* {:icon-id i/library}]]) [:div {:class (stl/css :grid-item-info)} [:div {:class (stl/css :grid-item-meta)} (if ^boolean editing? [:& inline-edition {:content (:name file) :on-end edit :max-length 250}] [:h3 {:class (stl/css :grid-item-title)} (:name file)]) [:> grid-item-metadata* {:file file :layout :grid}]] menu-element]]]))) (mf/defc grid* [{:keys [files project origin limit create-fn can-edit selected-files can-restore layout]}] (let [dragging? (mf/use-state false) list? (= layout :list) project-id (get project :id) team-id (get project :team-id) node-ref (mf/use-var nil) on-finish-import (mf/use-fn (mf/deps project-id team-id) (fn [] (st/emit! (dpj/fetch-files project-id) (dtm/fetch-shared-files team-id) (dd/clear-selected-files)))) import-files (use-import-file project-id on-finish-import) on-scroll (mf/use-fn #(st/emit! (dd/hide-file-menu))) on-drag-enter (mf/use-fn (fn [e] (when can-edit (when (and (not (dnd/has-type? e "penpot/files")) (or (dnd/has-type? e "Files") (dnd/has-type? e "application/x-moz-file"))) (dom/prevent-default e) (reset! dragging? true))))) on-drag-over (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-fn (fn [e] (when-not (dnd/from-child? e) (reset! dragging? false)))) on-drop (mf/use-fn (fn [e] (if can-edit (when (and (not (dnd/has-type? e "penpot/files")) (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)))) (dom/prevent-default e))))] [:div {:class (stl/css :dashboard-grid) :dragabble (dm/str can-edit) :on-drag-enter on-drag-enter :on-drag-over on-drag-over :on-drag-leave on-drag-leave :on-drop on-drop :on-scroll on-scroll :ref node-ref} (cond (nil? files) [:> loading-placeholder*] (and (seq files) list?) [:ul {:class (stl/css :grid-row :list-view)} (when @dragging? [:li {:class (stl/css :list-item-dragged)}]) (for [item files] [:> grid-item* {:file item :key (dm/str (:id item)) :origin origin :selected-files selected-files :can-edit can-edit :can-restore can-restore :layout :list}])] (seq files) (for [[index slice] (d/enumerate (partition-all limit files))] [:ul {:class (stl/css :grid-row) :key (dm/str index)} (when @dragging? [:li {:class (stl/css :grid-item)}]) (for [item slice] [:> grid-item* {:file item :key (dm/str (:id item)) :origin origin :selected-files selected-files :can-edit can-edit :can-restore can-restore}])]) :else [:> empty-grid-placeholder* {:limit limit :can-edit can-edit :create-fn create-fn :origin origin :project-id project-id :team-id team-id :on-finish-import on-finish-import}])])) (mf/defc line-grid-row* {::mf/private true} [{:keys [files selected-files is-dragging limit can-edit can-restore layout]}] (let [elements limit limit (if is-dragging (dec limit) limit) list? (= layout :list)] (if ^boolean list? [:ul {:class (stl/css :grid-row :list-view)} (when is-dragging [:li {:class (stl/css :list-item-dragged)}]) (for [item (take limit files)] [:> grid-item* {:id (:id item) :file item :selected-files selected-files :can-edit can-edit :key (dm/str (:id item)) :can-restore can-restore :layout :list}])] [:ul {:class (stl/css :grid-row :no-wrap) :style {:grid-template-columns (dm/str "repeat(" elements ", 1fr)")}} (when is-dragging [:li {:class (stl/css :grid-item :is-dragged)}]) (for [item (take limit files)] [:> grid-item* {:id (:id item) :file item :selected-files selected-files :can-edit can-edit :key (dm/str (:id item)) :can-restore can-restore}])]))) (mf/defc line-grid* [{:keys [project team files limit create-fn can-edit can-restore layout]}] (let [dragging? (mf/use-state false) project-id (:id project) team-id (:id team) selected-files (mf/deref refs/selected-files) selected-project (mf/deref refs/selected-project) on-finish-import (mf/use-fn (mf/deps team-id) (fn [] (st/emit! (dd/fetch-recent-files team-id) (dd/clear-selected-files)))) import-files (use-import-file project-id on-finish-import) on-drag-enter (mf/use-fn (mf/deps selected-project can-edit) (fn [e] (when can-edit (cond (dnd/has-type? e "penpot/files") (do (dom/prevent-default e) (when-not (or (dnd/from-child? e) (dnd/broken-event? e)) (when (not= selected-project project-id) (reset! dragging? true)))) (or (dnd/has-type? e "Files") (dnd/has-type? e "application/x-moz-file")) (do (dom/prevent-default e) (reset! dragging? true)))))) on-drag-over (mf/use-fn (fn [e] (when (or (dnd/has-type? e "penpot/files") (dnd/has-type? e "Files") (dnd/has-type? e "application/x-moz-file")) (dom/prevent-default e)))) on-drag-leave (mf/use-fn (fn [e] (when-not (dnd/from-child? e) (reset! dragging? false)))) on-drop-success (mf/use-fn (mf/deps team-id) (fn [] (st/emit! (ntf/success (tr "dashboard.success-move-file")) (dd/fetch-recent-files team-id) (dd/clear-selected-files)))) on-drop (mf/use-fn (mf/deps files selected-files can-edit) (fn [e] (if can-edit (cond (dnd/has-type? e "penpot/files") (do (reset! dragging? false) (when (not= selected-project project-id) (let [data {:ids (into #{} (keys selected-files)) :project-id project-id} mdata {:on-success on-drop-success}] (st/emit! (dd/move-files (with-meta data mdata)))))) (or (dnd/has-type? e "Files") (dnd/has-type? e "application/x-moz-file")) (do (dom/prevent-default e) (reset! dragging? false) (import-files (.-files (.-dataTransfer e))))) (dom/prevent-default e))))] [:div {:class (stl/css :dashboard-grid) :dragabble (dm/str can-edit) :on-drag-enter on-drag-enter :on-drag-over on-drag-over :on-drag-leave on-drag-leave :on-drop on-drop} (cond (nil? files) [:> loading-placeholder*] (seq files) [:> line-grid-row* {:files files :team-id team-id :selected-files selected-files :is-dragging @dragging? :can-edit can-edit :limit limit :can-restore can-restore :layout layout}] :else [:> empty-grid-placeholder* {:is-dragging @dragging? :limit limit :can-edit can-edit :create-fn create-fn :project-id project-id :team-id team-id :on-finish-import on-finish-import}])]))