From 1913395c475279491c4226b2ccdbe6697b22736b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 8 Sep 2023 14:50:58 +0200 Subject: [PATCH] :tada: Support for images as fills --- backend/src/app/tasks/file_gc.clj | 19 +- backend/test/backend_tests/rpc_file_test.clj | 155 +++++++ common/src/app/common/svg/shapes_builder.cljc | 7 +- common/src/app/common/types/color.cljc | 33 +- common/src/app/common/types/shape.cljc | 13 +- .../resources/images/colorpicker-no-image.png | Bin 0 -> 33750 bytes .../styles/main/partials/colorpicker.scss | 37 ++ .../styles/main/partials/inspect.scss | 2 + frontend/src/app/main/data/workspace.cljs | 96 +++-- .../src/app/main/data/workspace/colors.cljs | 105 +++-- .../app/main/data/workspace/libraries.cljs | 3 +- .../src/app/main/data/workspace/media.cljs | 155 ++++--- .../app/main/ui/components/color_bullet.cljs | 18 +- .../main/ui/components/color_bullet_new.cljs | 22 +- .../main/ui/components/color_bullet_new.scss | 3 + .../app/main/ui/components/shape_icon.cljs | 45 +- .../ui/components/shape_icon_refactor.cljs | 10 +- frontend/src/app/main/ui/shapes/attrs.cljs | 6 +- .../src/app/main/ui/shapes/custom_stroke.cljs | 71 ++- frontend/src/app/main/ui/shapes/export.cljs | 66 +-- frontend/src/app/main/ui/shapes/fills.cljs | 46 +- .../src/app/main/ui/shapes/gradients.cljs | 2 +- .../src/app/main/ui/shapes/text/svg_text.cljs | 6 +- .../ui/viewer/inspect/attributes/common.cljs | 198 ++++----- .../ui/viewer/inspect/attributes/common.scss | 8 +- .../ui/viewer/inspect/attributes/fill.cljs | 3 +- .../ui/viewer/inspect/attributes/stroke.cljs | 3 +- .../ui/viewer/inspect/attributes/text.cljs | 5 +- .../main/ui/viewer/inspect/right_sidebar.cljs | 3 +- .../app/main/ui/workspace/color_palette.cljs | 2 +- .../app/main/ui/workspace/colorpalette.cljs | 2 +- .../app/main/ui/workspace/colorpicker.cljs | 406 +++++++++++------- .../app/main/ui/workspace/colorpicker.scss | 39 +- .../ui/workspace/colorpicker/libraries.cljs | 7 +- .../options/menus/color_selection.cljs | 14 +- .../workspace/sidebar/options/menus/fill.cljs | 6 +- .../sidebar/options/menus/frame_grid.cljs | 10 +- .../sidebar/options/menus/shadow.cljs | 5 +- .../ui/workspace/sidebar/options/page.cljs | 2 + .../sidebar/options/rows/color_row.cljs | 8 + .../sidebar/options/rows/stroke_row.cljs | 8 +- frontend/src/app/util/color.cljs | 2 +- frontend/src/app/util/import/parser.cljs | 108 +++-- frontend/src/app/worker/import.cljs | 70 ++- frontend/translations/en.po | 18 + frontend/translations/es.po | 18 + 46 files changed, 1278 insertions(+), 587 deletions(-) create mode 100644 frontend/resources/images/colorpicker-no-image.png diff --git a/backend/src/app/tasks/file_gc.clj b/backend/src/app/tasks/file_gc.clj index 81c1f6767..6e6edabed 100644 --- a/backend/src/app/tasks/file_gc.clj +++ b/backend/src/app/tasks/file_gc.clj @@ -112,18 +112,21 @@ (let [xform (comp (map :objects) (mapcat vals) - (keep (fn [{:keys [type] :as obj}] - (case type - :path (get-in obj [:fill-image :id]) - :bool (get-in obj [:fill-image :id]) + (mapcat (fn [obj] ;; NOTE: because of some bug, we ended with ;; many shape types having the ability to ;; have fill-image attribute (which initially ;; designed for :path shapes). - :group (get-in obj [:fill-image :id]) - :image (get-in obj [:metadata :id]) - - nil)))) + (sequence + (keep :id) + (concat [(:fill-image obj) + (:metadata obj)] + (map :fill-image (:fills obj)) + (map :stroke-image (:strokes obj)) + (->> (:content obj) + (tree-seq map? :children) + (mapcat :fills) + (map :fill-image))))))) pages (concat (vals (:pages-index data)) (vals (:components data)))] diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 653491c52..eac02558c 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -364,6 +364,161 @@ (t/is (nil? (sto/get-object storage (:thumbnail-id fmo1)))) ))) +(t/deftest file-gc-image-fills-and-strokes + (letfn [(add-file-media-object [& {:keys [profile-id file-id]}] + (let [mfile {:filename "sample.jpg" + :path (th/tempfile "backend_tests/test_files/sample.jpg") + :mtype "image/jpeg" + :size 312043} + params {::th/type :upload-file-media-object + ::rpc/profile-id profile-id + :file-id file-id + :is-local true + :name "testfile" + :content mfile} + out (th/command! params)] + + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (:result out))) + + (update-file! [& {:keys [profile-id file-id changes revn] :or {revn 0}}] + (let [params {::th/type :update-file + ::rpc/profile-id profile-id + :id file-id + :session-id (uuid/random) + :revn revn + :components-v2 true + :changes changes} + out (th/command! params)] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (:result out)))] + + (let [storage (:app.storage/storage th/*system*) + + profile (th/create-profile* 1) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + + fmo1 (add-file-media-object :profile-id (:id profile) :file-id (:id file)) + fmo2 (add-file-media-object :profile-id (:id profile) :file-id (:id file)) + fmo3 (add-file-media-object :profile-id (:id profile) :file-id (:id file)) + fmo4 (add-file-media-object :profile-id (:id profile) :file-id (:id file)) + fmo5 (add-file-media-object :profile-id (:id profile) :file-id (:id file)) + s-shid (uuid/random) + t-shid (uuid/random) + + page-id (first (get-in file [:data :pages]))] + + ;; Update file inserting a new image object + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :changes + [{:type :add-obj + :page-id page-id + :id s-shid + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id s-shid + :name "image" + :frame-id uuid/zero + :parent-id uuid/zero + :type :image + :metadata {:id (:id fmo1) :width 100 :height 100 :mtype "image/jpeg"} + :fills [{:opacity 1 :fill-image {:id (:id fmo2) :width 100 :height 100 :mtype "image/jpeg"}}] + :strokes [{:opacity 1 :stroke-image {:id (:id fmo3) :width 100 :height 100 :mtype "image/jpeg"}}]})} + {:type :add-obj + :page-id page-id + :id t-shid + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id t-shid + :name "text" + :frame-id uuid/zero + :parent-id uuid/zero + :type :text + :content {:type "root" + :children [{:type "paragraph-set" + :children [{:type "paragraph" + :children [{:fills [{:fill-opacity 1 + :fill-image {:id (:id fmo4) + :width 417 + :height 354 + :mtype "image/png" + :name "text fill image"}}] + :text "hi"} + {:fills [{:fill-opacity 1 + :fill-color "#000000"}] + :text "bye"}]}]}]} + :strokes [{:opacity 1 :stroke-image {:id (:id fmo5) :width 100 :height 100 :mtype "image/jpeg"}}]})}]) + + ;; run the file-gc task immediately without forced min-age + (let [res (th/run-task! "file-gc")] + (t/is (= 0 (:processed res)))) + + ;; run the task again + (let [res (th/run-task! "file-gc" {:min-age 0})] + (t/is (= 1 (:processed res)))) + + ;; retrieve file and check trimmed attribute + (let [row (th/db-get :file {:id (:id file)})] + (t/is (true? (:has-media-trimmed row)))) + + ;; check file media objects + (let [rows (th/db-exec! ["select * from file_media_object where file_id = ?" (:id file)])] + (t/is (= 5 (count rows)))) + + ;; The underlying storage objects are still available. + (t/is (some? (sto/get-object storage (:media-id fmo5)))) + (t/is (some? (sto/get-object storage (:media-id fmo4)))) + (t/is (some? (sto/get-object storage (:media-id fmo3)))) + (t/is (some? (sto/get-object storage (:media-id fmo2)))) + (t/is (some? (sto/get-object storage (:media-id fmo1)))) + + ;; proceed to remove usage of the file + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :changes [{:type :del-obj + :page-id (first (get-in file [:data :pages])) + :id s-shid} + {:type :del-obj + :page-id (first (get-in file [:data :pages])) + :id t-shid}]) + + ;; Now, we have deleted the usage of pointers to the + ;; file-media-objects, if we paste file-gc, they should be marked + ;; as deleted. + (let [task (:app.tasks.file-gc/handler th/*system*) + res (task {:min-age (dt/duration 0)})] + (t/is (= 1 (:processed res)))) + + ;; Now that file-gc have deleted the file-media-object usage, + ;; lets execute the touched-gc task, we should see that two of + ;; them are marked to be deleted. + (let [task (:app.storage/gc-touched-task th/*system*) + res (task {:min-age (dt/duration 0)})] + (t/is (= 0 (:freeze res))) + (t/is (= 2 (:delete res)))) + + ;; Finally, check that some of the objects that are marked as + ;; deleted we are unable to retrieve them using standard storage + ;; public api. + (t/is (nil? (sto/get-object storage (:media-id fmo5)))) + (t/is (nil? (sto/get-object storage (:media-id fmo4)))) + (t/is (nil? (sto/get-object storage (:media-id fmo3)))) + (t/is (nil? (sto/get-object storage (:media-id fmo2)))) + (t/is (nil? (sto/get-object storage (:media-id fmo1))))))) + (t/deftest permissions-checks-creating-file (let [profile1 (th/create-profile* 1) profile2 (th/create-profile* 2) diff --git a/common/src/app/common/svg/shapes_builder.cljc b/common/src/app/common/svg/shapes_builder.cljc index d14eafeca..632b1483d 100644 --- a/common/src/app/common/svg/shapes_builder.cljc +++ b/common/src/app/common/svg/shapes_builder.cljc @@ -329,7 +329,8 @@ image-url (or (:href attrs) (:xlink:href attrs)) image-data (dm/get-in svg-data [:image-data image-url]) - metadata {:width (:width image-data) + metadata {:name name + :width (:width image-data) :height (:height image-data) :mtype (:mtype image-data) :id (:id image-data)} @@ -344,9 +345,11 @@ (when (some? image-data) (cts/setup-shape (-> (calculate-rect-metadata rect transform) - (assoc :type :image) + (assoc :type :rect) (assoc :name name) (assoc :frame-id frame-id) + (assoc :fills [{:fill-opacity 1 + :fill-image metadata}]) (assoc :metadata metadata) (assoc :svg-viewbox rect) (assoc :svg-attrs props)))))) diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index 7af88eb33..4c1b988f5 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -46,6 +46,14 @@ ::oapi/type "integer" ::oapi/format "int64"}}) +(sm/def! ::image-color + [:map {:title "ImageColor"} + [:name {:optional true} :string] + [:width :int] + [:height :int] + [:mtype {:optional true} [:maybe :string]] + [:id ::sm/uuid]]) + (sm/def! ::gradient [:map {:title "Gradient"} [:type [::sm/one-of #{:linear :radial "linear" "radial"}]] @@ -72,17 +80,19 @@ [:modified-at {:optional true} ::sm/inst] [:ref-id {:optional true} ::sm/uuid] [:ref-file {:optional true} ::sm/uuid] - [:gradient {:optional true} [:maybe ::gradient]]]) + [:gradient {:optional true} [:maybe ::gradient]] + [:image {:optional true} [:maybe ::image-color]]]) ;; FIXME: incomplete schema (sm/def! ::recent-color [:and - [:map {:title "RecentColot"} + [:map {:title "RecentColor"} [:opacity {:optional true} [:maybe ::sm/safe-number]] [:color {:optional true} [:maybe ::rgb-color]] - [:gradient {:optional true} [:maybe ::gradient]]] - [::sm/contains-any {:strict true} [:color :gradient]]]) + [:gradient {:optional true} [:maybe ::gradient]] + [:image {:optional true} [:maybe ::image-color]]] + [::sm/contains-any {:strict true} [:color :gradient :image]]]) (def color? (sm/pred-fn ::color)) @@ -102,17 +112,19 @@ {:color (:fill-color fill) :opacity (:fill-opacity fill) :gradient (:fill-color-gradient fill) + :image (:fill-image fill) :ref-id (:fill-color-ref-id fill) :ref-file (:fill-color-ref-file fill)})) (defn set-fill-color - [shape position color opacity gradient] + [shape position color opacity gradient image] (update-in shape [:fills position] (fn [fill] (d/without-nils (assoc fill :fill-color color :fill-opacity opacity - :fill-color-gradient gradient))))) + :fill-color-gradient gradient + :fill-image image))))) (defn attach-fill-color [shape position ref-id ref-file] @@ -133,17 +145,19 @@ (d/without-nils {:color (:stroke-color stroke) :opacity (:stroke-opacity stroke) :gradient (:stroke-color-gradient stroke) + :image (:stroke-image stroke) :ref-id (:stroke-color-ref-id stroke) :ref-file (:stroke-color-ref-file stroke)})) (defn set-stroke-color - [shape position color opacity gradient] + [shape position color opacity gradient image] (update-in shape [:strokes position] (fn [stroke] (d/without-nils (assoc stroke :stroke-color color :stroke-opacity opacity - :stroke-color-gradient gradient))))) + :stroke-color-gradient gradient + :stroke-image image))))) (defn attach-stroke-color [shape position ref-id ref-file] @@ -336,7 +350,8 @@ position (:color library-color) (:opacity library-color) - (:gradient library-color)) + (:gradient library-color) + (:image library-color)) (detach-fn shape position))) shape))] diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 37c8bbe31..3944f2af9 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -105,7 +105,8 @@ [:fill-opacity {:optional true} ::sm/safe-number] [:fill-color-gradient {:optional true} [:maybe ::ctc/gradient]] [:fill-color-ref-file {:optional true} [:maybe ::sm/uuid]] - [:fill-color-ref-id {:optional true} [:maybe ::sm/uuid]]]) + [:fill-color-ref-id {:optional true} [:maybe ::sm/uuid]] + [:fill-image {:optional true} ::ctc/image-color]]) (sm/def! ::stroke [:map {:title "Stroke"} @@ -122,7 +123,8 @@ [::sm/one-of stroke-caps]] [:stroke-cap-end {:optional true} [::sm/one-of stroke-caps]] - [:stroke-color-gradient {:optional true} ::ctc/gradient]]) + [:stroke-color-gradient {:optional true} ::ctc/gradient] + [:stroke-image {:optional true} ::ctc/image-color]]) (sm/def! ::minimal-shape-attrs [:map {:title "ShapeMinimalRecord"} @@ -346,6 +348,13 @@ (def valid-shape? (sm/pred-fn ::shape)) + +(defn has-images? + [{:keys [fills strokes]}] + (or + (some :fill-image fills) + (some :stroke-image strokes))) + ;; --- Initialization (def ^:private minimal-rect-attrs diff --git a/frontend/resources/images/colorpicker-no-image.png b/frontend/resources/images/colorpicker-no-image.png new file mode 100644 index 0000000000000000000000000000000000000000..2ba94601b3066c462e4b100cd6202ab32af67281 GIT binary patch literal 33750 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 literal 0 HcmV?d00001 diff --git a/frontend/resources/styles/main/partials/colorpicker.scss b/frontend/resources/styles/main/partials/colorpicker.scss index 74600eee5..2ad36757d 100644 --- a/frontend/resources/styles/main/partials/colorpicker.scss +++ b/frontend/resources/styles/main/partials/colorpicker.scss @@ -21,6 +21,7 @@ .top-actions { display: flex; margin-bottom: $size-1; + flex-direction: row-reverse; justify-content: space-between; .picker-btn { @@ -38,8 +39,44 @@ height: 14px; } } + + .element-set-content { + width: auto; + padding: 0.25rem 0; + .custom-select { + border: none; + &:hover { + border: none; + } + .custom-select-dropdown { + left: auto; + right: 0; + } + } + } } + .select-image { + .content { + display: flex; + justify-content: center; + background-image: url("/images/colorpicker-no-image.png"); + background-position: center; + background-size: auto 6.75rem; + height: 6.75rem; + img { + height: fit-content; + width: fit-content; + max-height: 100%; + max-width: 100%; + margin: auto; + } + } + button { + width: 100%; + margin-top: 10px; + } + } .gradients-buttons { .gradient { cursor: pointer; diff --git a/frontend/resources/styles/main/partials/inspect.scss b/frontend/resources/styles/main/partials/inspect.scss index b2f8a9648..bc83f6c05 100644 --- a/frontend/resources/styles/main/partials/inspect.scss +++ b/frontend/resources/styles/main/partials/inspect.scss @@ -143,6 +143,8 @@ .color-text { width: 3rem; text-transform: uppercase; + text-overflow: ellipsis; + overflow: hidden; } .attributes-color-display { diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index dabc8ecf6..89e3c86a7 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -1530,27 +1530,45 @@ ;; for retrieve the image data and convert it to the ;; data-url. (prepare-object [objects parent-frame-id {:keys [type] :as obj}] - (let [obj (maybe-translate obj objects parent-frame-id)] - (if (= type :image) - (let [url (cf/resolve-file-media (:metadata obj))] - (->> (http/send! {:method :get - :uri url - :response-type :blob}) - (rx/map :body) - (rx/mapcat wapi/read-file-as-data-url) - (rx/map #(assoc obj ::data %)) - (rx/take 1))) + (let [obj (maybe-translate obj objects parent-frame-id) + ;; Texts can have different fills for pieces of the text + fill-images-data (->> (or (:position-data obj) [obj]) + (map :fills) + (reduce into []) + (filter :fill-image) + (map :fill-image)) + + stroke-images-data (->> (:strokes obj) + (filter :stroke-image) + (map :stroke-image)) + images-data (concat + fill-images-data + stroke-images-data + (when (= type :image) + [(:metadata obj)]))] + + (if (> (count images-data) 0) + (->> (rx/from images-data) + (rx/mapcat (fn [image-data] + (let [url (cf/resolve-file-media image-data)] + (->> (http/send! {:method :get + :uri url + :response-type :blob}) + (rx/map :body) + (rx/mapcat wapi/read-file-as-data-url) + (rx/map #(assoc image-data :data %)))))) + (rx/reduce conj []) + (rx/map + (fn [images] + (assoc obj ::data images)))) (rx/of obj)))) ;; Collects all the items together and split images into a ;; separated data structure for a more easy paste process. - (collect-data [res {:keys [id metadata] :as item}] + (collect-data [res {:keys [id] :as item}] (let [res (update res :objects assoc id (dissoc item ::data))] - (if (= :image (:type item)) - (let [img-part {:id (:id metadata) - :name (:name item) - :file-data (::data item)}] - (update res :images conj img-part)) + (if (::data item) + (update res :images into (::data item)) res))) (maybe-translate [shape objects parent-frame-id] @@ -1713,7 +1731,7 @@ (letfn [;; Given a file-id and img (part generated by the ;; copy-selected event), uploads the new media. (upload-media [file-id imgpart] - (->> (http/send! {:uri (:file-data imgpart) + (->> (http/send! {:uri (:data imgpart) :response-type :blob :method :get}) (rx/map :body) @@ -1727,19 +1745,43 @@ (rx/map (fn [media] (assoc media :prev-id (:id imgpart)))))) + (translate-staled-media [mdata attribute media-idx] + (let [id (get-in mdata [attribute :id]) + mobj (get media-idx id)] + (if mobj + (update mdata attribute #(assoc % + :id (:id mobj) + :path (:path mobj))) + mdata))) + ;; Analyze the rchange and replace staled media and ;; references to the new uploaded media-objects. (process-rchange [media-idx item] - (if (and (= (:type item) :add-obj) - (= :image (get-in item [:obj :type]))) - (update-in item [:obj :metadata] - (fn [{:keys [id] :as mdata}] - (if-let [mobj (get media-idx id)] - (assoc mdata - :id (:id mobj) - :path (:path mobj)) - mdata))) - item)) + (let [;; Texts can have different fills for pieces of the text + obj (:obj item) + fills (mapv #(translate-staled-media % :fill-image media-idx) (:fills obj)) + strokes (mapv #(translate-staled-media % :stroke-image media-idx) (:strokes obj)) + position-data (->> (:position-data obj) + (mapv (fn [p-data] + (let [fills (mapv #(translate-staled-media % :fill-image media-idx) (:fills p-data))] + (assoc p-data :fills fills))))) + content (txt/transform-nodes #(translate-staled-media % :fill-image media-idx) (:content obj))] + + (if (= (:type item) :add-obj) + (-> item + (update-in [:obj :metadata] + (fn [{:keys [id] :as mdata}] + (if-let [mobj (get media-idx id)] + (assoc mdata + :id (:id mobj) + :path (:path mobj)) + mdata))) + (assoc-in [:obj :fills] fills) + (assoc-in [:obj :strokes] strokes) + (assoc-in [:obj :content] content) + (cond-> + (> (count position-data) 0) (assoc-in [:obj :position-data] position-data))) + item))) (calculate-paste-position [state mouse-pos in-viewport?] (let [page-objects (wsh/lookup-page-objects state) diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index 77a42e088..eba8fbab6 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -105,6 +105,9 @@ (contains? color :opacity) (assoc :fill-opacity (:opacity color)) + (contains? color :image) + (assoc :fill-image (:image color)) + :always (d/without-nils)) @@ -223,9 +226,15 @@ (assoc :stroke-color-gradient (:gradient attrs)) (contains? attrs :opacity) - (assoc :stroke-opacity (:opacity attrs))) + (assoc :stroke-opacity (:opacity attrs)) - attrs (merge attrs color-attrs)] + (contains? attrs :image) + (assoc :stroke-image (:image attrs))) + + attrs (-> + (merge attrs color-attrs) + (dissoc :image) + (dissoc :gradient))] (rx/of (dch/update-shapes ids @@ -455,7 +464,11 @@ (defn clear-color-components [data] - (dissoc data :hex :alpha :r :g :b :h :s :v)) + (dissoc data :hex :alpha :r :g :b :h :s :v :image)) + +(defn clear-image-components + [data] + (dissoc data :hex :alpha :r :g :b :h :s :v :color)) (defn- create-gradient [type] @@ -467,8 +480,14 @@ (defn get-color-from-colorpicker-state [{:keys [type current-color stops gradient] :as state}] - (if (= type :color) + (cond + (= type :color) (clear-color-components current-color) + + (= type :image) + (clear-image-components current-color) + + :else {:gradient (-> gradient (assoc :type (case type :linear-gradient :linear @@ -487,7 +506,7 @@ (on-change color))))) (defn initialize-colorpicker - [on-change] + [on-change tab] (ptk/reify ::initialize-colorpicker ptk/WatchEvent (watch [_ _ stream] @@ -502,7 +521,14 @@ (rx/filter (ptk/type? ::update-colorpicker-color) stream) (rx/filter (ptk/type? ::activate-colorpicker-gradient) stream)) (rx/map (constantly (colorpicker-onchange-runner on-change))) - (rx/take-until stoper)))))) + (rx/take-until stoper)))) + + ptk/UpdateEvent + (update [_ state] + (update state :colorpicker + (fn [state] + (-> state + (assoc :type tab))))))) (defn finalize-colorpicker [] @@ -522,13 +548,8 @@ (let [current-color (:current-color state)] (if (some? gradient) (let [stop (or (:editing-stop state) 0) - stops (mapv split-color-components (:stops gradient)) - type (case (:type gradient) - :linear :linear-gradient - :radial :radial-gradient - (:type state))] + stops (mapv split-color-components (:stops gradient))] (-> state - (assoc :type type) (assoc :current-color (nth stops stop)) (assoc :stops stops) (assoc :gradient (-> gradient @@ -537,7 +558,6 @@ (assoc :editing-stop stop))) (-> state - (assoc :type :color) (cond-> (or (nil? current-color) (not= (:color data) (:color current-color)) (not= (:opacity data) (:opacity current-color))) @@ -553,9 +573,11 @@ (update [_ state] (update state :colorpicker (fn [state] - (let [state (-> state + (let [type (:type state) + state (-> state (update :current-color merge changes) (update :current-color materialize-color-components) + (update :current-color #(if (not= type :image) (dissoc % :image) %)) ;; current color can be a library one I'm changing via colorpicker (d/dissoc-in [:current-color :id]) (d/dissoc-in [:current-color :file-id]))] @@ -564,7 +586,6 @@ (merge data) (materialize-color-components)))) (-> state - (assoc :type :color) (dissoc :gradient :stops :editing-stop))))))) ptk/WatchEvent (watch [_ state _] @@ -592,6 +613,17 @@ :editing-stop stop) state)))))) +(defn activate-colorpicker-color + [] + (ptk/reify ::activate-colorpicker-color + ptk/UpdateEvent + (update [_ state] + (update state :colorpicker + (fn [state] + (-> state + (assoc :type :color) + (dissoc :editing-stop :stops :gradient))))))) + (defn activate-colorpicker-gradient [type] (ptk/reify ::activate-colorpicker-gradient @@ -599,23 +631,32 @@ (update [_ state] (update state :colorpicker (fn [state] - (if (= type (:type state)) - (do - (-> state - (assoc :type :color) - (dissoc :editing-stop :stops :gradient))) - (let [gradient (create-gradient type) - color (:current-color state)] - (-> state - (assoc :type type) - (assoc :gradient gradient) - (cond-> (not (:stops state)) - (assoc :editing-stop 0 - :stops [(assoc color :offset 0) - (-> color - (assoc :alpha 0) - (assoc :offset 1) - (materialize-color-components))])))))))))) + (let [gradient (create-gradient type) + color (:current-color state)] + (-> state + (assoc :type type) + (assoc :gradient gradient) + (d/dissoc-in [:current-color :image]) + (cond-> (not (:stops state)) + (assoc :editing-stop 0 + :stops [(-> color + (assoc :offset 0) + (materialize-color-components)) + (-> color + (assoc :alpha 0) + (assoc :offset 1) + (materialize-color-components))]))))))))) + +(defn activate-colorpicker-image + [] + (ptk/reify ::activate-colorpicker-image + ptk/UpdateEvent + (update [_ state] + (update state :colorpicker + (fn [state] + (-> state + (assoc :type :image) + (dissoc :editing-stop :stops :gradient))))))) (defn select-color [position add-color] diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index c1fbac8df..e3e8b7004 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -97,7 +97,8 @@ (let [id (uuid/next) color (-> color (assoc :id id) - (assoc :name (or (:color color) + (assoc :name (or (get-in color [:image :name]) + (:color color) (uc/gradient-type->string (get-in color [:gradient :type])))))] (dm/assert! ::ctc/color color) (ptk/reify ::add-color diff --git a/frontend/src/app/main/data/workspace/media.cljs b/frontend/src/app/main/data/workspace/media.cljs index cac7ba07c..115a5c1da 100644 --- a/frontend/src/app/main/data/workspace/media.cljs +++ b/frontend/src/app/main/data/workspace/media.cljs @@ -78,11 +78,13 @@ :height height :x (mth/round (- x (/ width 2))) :y (mth/round (- y (/ height 2))) - :metadata {:width width - :height height - :mtype mtype - :id id}}] - (rx/of (dwsh/create-and-add-shape :image x y shape)))))) + :fills [{:fill-opacity 1 + :fill-image {:name name + :width width + :height height + :mtype mtype + :id id}}]}] + (rx/of (dwsh/create-and-add-shape :rect x y shape)))))) (defn svg-uploaded [svg-data file-id position] @@ -171,64 +173,64 @@ [:uris {:optional true} [:sequential :string]] [:mtype {:optional true} :string]]) +(defn handle-media-error [error on-error] + (if (ex/ex-info? error) + (handle-media-error (ex-data error) on-error) + (cond + (= (:code error) :invalid-svg-file) + (rx/of (msg/error (tr "errors.media-type-not-allowed"))) + + (= (:code error) :media-type-not-allowed) + (rx/of (msg/error (tr "errors.media-type-not-allowed"))) + + (= (:code error) :unable-to-access-to-url) + (rx/of (msg/error (tr "errors.media-type-not-allowed"))) + + (= (:code error) :invalid-image) + (rx/of (msg/error (tr "errors.media-type-not-allowed"))) + + (= (:code error) :media-max-file-size-reached) + (rx/of (msg/error (tr "errors.media-too-large"))) + + (= (:code error) :media-type-mismatch) + (rx/of (msg/error (tr "errors.media-type-mismatch"))) + + (= (:code error) :unable-to-optimize) + (rx/of (msg/error (:hint error))) + + (fn? on-error) + (on-error error) + + :else + (do + (.error js/console "ERROR" error) + (rx/of (msg/error (tr "errors.cannot-upload"))))))) + (defn- process-media-objects [{:keys [uris on-error] :as params}] (dm/assert! - (and (sm/valid? schema:process-media-objects params) - (or (contains? params :blobs) - (contains? params :uris)))) + (and (sm/valid? schema:process-media-objects params) + (or (contains? params :blobs) + (contains? params :uris)))) - (letfn [(handle-error [error] - (if (ex/ex-info? error) - (handle-error (ex-data error)) - (cond - (= (:code error) :invalid-svg-file) - (rx/of (msg/error (tr "errors.media-type-not-allowed"))) - - (= (:code error) :media-type-not-allowed) - (rx/of (msg/error (tr "errors.media-type-not-allowed"))) - - (= (:code error) :unable-to-access-to-url) - (rx/of (msg/error (tr "errors.media-type-not-allowed"))) - - (= (:code error) :invalid-image) - (rx/of (msg/error (tr "errors.media-type-not-allowed"))) - - (= (:code error) :media-max-file-size-reached) - (rx/of (msg/error (tr "errors.media-too-large"))) - - (= (:code error) :media-type-mismatch) - (rx/of (msg/error (tr "errors.media-type-mismatch"))) - - (= (:code error) :unable-to-optimize) - (rx/of (msg/error (:hint error))) - - (fn? on-error) - (on-error error) - - :else - (do - (.error js/console "ERROR" error) - (rx/of (msg/error (tr "errors.cannot-upload")))))))] - - (ptk/reify ::process-media-objects - ptk/WatchEvent - (watch [_ _ _] - (rx/concat - (rx/of (msg/show {:content (tr "media.loading") - :type :info - :timeout nil - :tag :media-loading})) - (->> (if (seq uris) + (ptk/reify ::process-media-objects + ptk/WatchEvent + (watch [_ _ _] + (rx/concat + (rx/of (msg/show {:content (tr "media.loading") + :type :info + :timeout nil + :tag :media-loading})) + (->> (if (seq uris) ;; Media objects is a list of URL's pointing to the path - (process-uris params) + (process-uris params) ;; Media objects are blob of data to be upload - (process-blobs params)) + (process-blobs params)) ;; Every stream has its own sideeffect. We need to ignore the result - (rx/ignore) - (rx/catch handle-error) - (rx/finalize #(st/emit! (msg/hide-tag :media-loading))))))))) + (rx/ignore) + (rx/catch #(handle-media-error % on-error)) + (rx/finalize #(st/emit! (msg/hide-tag :media-loading)))))))) ;; Deprecated in components-v2 (defn upload-media-asset @@ -248,6 +250,35 @@ (process-media-objects params))) + +(defn upload-fill-image + [file on-success] + (dm/assert! + "expected a valid blob for `file` param" + (dmm/blob? file)) + (ptk/reify ::upload-fill-image + ptk/WatchEvent + (watch [_ state _] + (let [on-upload-success + (fn [image] + (on-success image) + (dmm/notify-finished-loading)) + + prepare + (fn [content] + {:file-id (get-in state [:workspace-file :id]) + :name (if (dmm/file? content) (.-name content) (tr "media.image")) + :is-local false + :content content})] + + (dmm/notify-start-loading) + (->> (rx/of file) + (rx/map dmm/validate-file) + (rx/map prepare) + (rx/mapcat #(rp/cmd! :upload-file-media-object %)) + (rx/do on-upload-success) + (rx/catch handle-media-error)))))) + ;; --- Upload File Media objects (defn load-and-parse-svg @@ -283,7 +314,7 @@ (defn create-shapes-img "Convert a media object that contains a bitmap image into shapes, - one shape of type :image and one group that contains it." + one shape of type :rect containing an image fill and one group that contains it." [pos {:keys [name width height id mtype] :as media-obj} & {:keys [wrapper-type] :or {wrapper-type :group}}] (let [group-shape (cts/setup-shape {:type wrapper-type @@ -296,15 +327,17 @@ :parent-id uuid/zero}) img-shape (cts/setup-shape - {:type :image + {:type :rect :x (:x pos) :y (:y pos) :width width :height height - :metadata {:id id - :width width - :height height - :mtype mtype} + :fills [{:fill-opacity 1 + :fill-image {:name name + :id id + :width width + :height height + :mtype mtype}}] :name name :frame-id uuid/zero :parent-id (:id group-shape)})] diff --git a/frontend/src/app/main/ui/components/color_bullet.cljs b/frontend/src/app/main/ui/components/color_bullet.cljs index d4a151435..23be4183a 100644 --- a/frontend/src/app/main/ui/components/color_bullet.cljs +++ b/frontend/src/app/main/ui/components/color_bullet.cljs @@ -6,8 +6,11 @@ (ns app.main.ui.components.color-bullet (:require + [app.config :as cfg] [app.util.color :as uc] [app.util.dom :as dom] + [app.util.i18n :as i18n :refer [tr]] + [cuerdas.core :as str] [rumext.v2 :as mf])) (mf/defc color-bullet @@ -31,8 +34,15 @@ :is-gradient (some? (:gradient color))) :on-click on-click :title (uc/get-color-name color)} - (if (:gradient color) + (cond + (:gradient color) [:div.color-bullet-wrapper {:style {:background (uc/color->background color)}}] + + (:image color) + (let [uri (cfg/resolve-file-media (:image color))] + [:div.color-bullet-wrapper {:style {:background-size "contain" :background-image (str/ffmt "url(%)" uri)}}]) + + :else [:div.color-bullet-wrapper [:div.color-bullet-left {:style {:background (uc/color->background (assoc color :opacity 1))}}] [:div.color-bullet-right {:style {:background (uc/color->background color)}}]])])))) @@ -40,10 +50,12 @@ (mf/defc color-name {::mf/wrap-props false} [{:keys [color size on-click on-double-click]}] - (let [{:keys [name color gradient]} (if (string? color) {:color color :opacity 1} color)] + (let [{:keys [name color gradient image]} (if (string? color) {:color color :opacity 1} color)] (when (or (not size) (= size :big)) [:span.color-text {:on-click on-click :on-double-click on-double-click :title name} - (or name color (uc/gradient-type->string (:type gradient)))]))) + (if (some? image) + (tr "media.image") + (or name color (uc/gradient-type->string (:type gradient))))]))) diff --git a/frontend/src/app/main/ui/components/color_bullet_new.cljs b/frontend/src/app/main/ui/components/color_bullet_new.cljs index 34686068e..4281edf62 100644 --- a/frontend/src/app/main/ui/components/color_bullet_new.cljs +++ b/frontend/src/app/main/ui/components/color_bullet_new.cljs @@ -7,7 +7,10 @@ (ns app.main.ui.components.color-bullet-new (:require-macros [app.main.style :as stl]) (:require + [app.config :as cfg] [app.util.color :as uc] + [app.util.i18n :as i18n :refer [tr]] + [cuerdas.core :as str] [rumext.v2 :as mf])) (mf/defc color-bullet @@ -26,7 +29,8 @@ (let [color (if (string? color) {:color color :opacity 1} color) id (:id color) gradient (:gradient color) - opacity (:opacity color)] + opacity (:opacity color) + image (:image color)] [:div {:class (stl/css-case :color-bullet true @@ -38,21 +42,27 @@ :grid-area area) :on-click on-click} - (if (some? gradient) + (cond + (some? gradient) [:div {:class (stl/css :color-bullet-wrapper) :style {:background (uc/color->background color)}}] + (some? image) + (let [uri (cfg/resolve-file-media image)] + [:div {:class (stl/css :color-bullet-wrapper) + :style {:background-image (str/ffmt "url(%)" uri)}}]) + + :else [:div {:class (stl/css :color-bullet-wrapper)} [:div {:class (stl/css :color-bullet-left) :style {:background (uc/color->background (assoc color :opacity 1))}}] [:div {:class (stl/css :color-bullet-right) :style {:background (uc/color->background color)}}]])])))) - (mf/defc color-name {::mf/wrap-props false} [{:keys [color size on-click on-double-click]}] - (let [{:keys [name color gradient]} (if (string? color) {:color color :opacity 1} color)] + (let [{:keys [name color gradient image]} (if (string? color) {:color color :opacity 1} color)] (when (or (not size) (> size 64)) [:span {:class (stl/css-case :color-text (< size 72) @@ -60,4 +70,6 @@ :big-text (>= size 72)) :on-click on-click :on-double-click on-double-click} - (or name color (uc/gradient-type->string (:type gradient)))]))) + (if (some? image) + (tr "media.image") + (or name color (uc/gradient-type->string (:type gradient))))]))) diff --git a/frontend/src/app/main/ui/components/color_bullet_new.scss b/frontend/src/app/main/ui/components/color_bullet_new.scss index d47fa2321..bb8785b92 100644 --- a/frontend/src/app/main/ui/components/color_bullet_new.scss +++ b/frontend/src/app/main/ui/components/color_bullet_new.scss @@ -55,6 +55,9 @@ height: 100%; width: 100%; clip-path: circle(50%); + background-size: contain; + background-repeat: no-repeat; + background-position: center; } .color-bullet-wrapper > * { width: 100%; diff --git a/frontend/src/app/main/ui/components/shape_icon.cljs b/frontend/src/app/main/ui/components/shape_icon.cljs index ab69c224d..af02b820f 100644 --- a/frontend/src/app/main/ui/components/shape_icon.cljs +++ b/frontend/src/app/main/ui/components/shape_icon.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.components.shape-icon (:require [app.common.types.component :as ctk] + [app.common.types.shape :as cts] [app.common.types.shape.layout :as ctl] [app.main.ui.icons :as i] [rumext.v2 :as mf])) @@ -32,10 +33,10 @@ :else i/artboard) :image i/image - :line i/line - :circle i/circle - :path i/curve - :rect i/box + :line (if (cts/has-images? shape) i/image i/line) + :circle (if (cts/has-images? shape) i/image i/circle) + :path (if (cts/has-images? shape) i/image i/curve) + :rect (if (cts/has-images? shape) i/image i/box) :text i/text :group (if (:masked-group shape) i/mask @@ -47,39 +48,3 @@ #_:default i/bool-union) :svg-raw i/file-svg nil))) - -(mf/defc element-icon-refactor - [{:keys [shape main-instance?] :as props}] - (if (ctk/instance-head? shape) - (if main-instance? - i/component-refactor - i/copy-refactor) - (case (:type shape) - :frame (cond - (and (ctl/flex-layout? shape) (ctl/col? shape)) - i/flex-vertical-refactor - - (and (ctl/flex-layout? shape) (ctl/row? shape)) - i/flex-horizontal-refactor - - (ctl/grid-layout? shape) - i/grid-refactor - - :else - i/board-refactor) - :image i/img-refactor - :line i/path-refactor - :circle i/elipse-refactor - :path i/curve-refactor - :rect i/rectangle-refactor - :text i/text-refactor - :group (if (:masked-group shape) - i/mask-refactor - i/group-refactor) - :bool (case (:bool-type shape) - :difference i/boolean-difference-refactor - :exclude i/boolean-exclude-refactor - :intersection i/boolean-intersection-refactor - #_:default i/boolean-union-refactor) - :svg-raw i/svg-refactor - nil))) diff --git a/frontend/src/app/main/ui/components/shape_icon_refactor.cljs b/frontend/src/app/main/ui/components/shape_icon_refactor.cljs index 190117dd5..3ed99b6b9 100644 --- a/frontend/src/app/main/ui/components/shape_icon_refactor.cljs +++ b/frontend/src/app/main/ui/components/shape_icon_refactor.cljs @@ -7,11 +7,11 @@ (ns app.main.ui.components.shape-icon-refactor (:require [app.common.types.component :as ctk] + [app.common.types.shape :as cts] [app.common.types.shape.layout :as ctl] [app.main.ui.icons :as i] [rumext.v2 :as mf])) - (mf/defc element-icon-refactor {::mf/wrap-props false} [{:keys [shape main-instance?]}] @@ -33,10 +33,10 @@ i/board-refactor) ;; TODO -> THUMBNAIL ICON :image i/img-refactor - :line i/path-refactor - :circle i/elipse-refactor - :path i/path-refactor - :rect i/rectangle-refactor + :line (if (cts/has-images? shape) i/img-refactor i/path-refactor) + :circle (if (cts/has-images? shape) i/img-refactor i/elipse-refactor) + :path (if (cts/has-images? shape) i/img-refactor i/curve-refactor) + :rect (if (cts/has-images? shape) i/img-refactor i/rectangle-refactor) :text i/text-refactor :group (if (:masked-group shape) i/mask-refactor diff --git a/frontend/src/app/main/ui/shapes/attrs.cljs b/frontend/src/app/main/ui/shapes/attrs.cljs index 164eff602..14bee9725 100644 --- a/frontend/src/app/main/ui/shapes/attrs.cljs +++ b/frontend/src/app/main/ui/shapes/attrs.cljs @@ -62,14 +62,14 @@ (defn add-fill! [attrs fill-data render-id index type] - (let [index (if (some? index) (dm/str "_" index) "")] + (let [index (if (some? index) (dm/str "-" index) "")] (cond (contains? fill-data :fill-image) (let [id (dm/str "fill-image-" render-id)] (obj/set! attrs "fill" (dm/str "url(#" id ")"))) (some? (:fill-color-gradient fill-data)) - (let [id (dm/str "fill-color-gradient_" render-id index)] + (let [id (dm/str "fill-color-gradient-" render-id index)] (obj/set! attrs "fill" (dm/str "url(#" id ")"))) (contains? fill-data :fill-color) @@ -100,7 +100,7 @@ (obj/set! attrs "strokeWidth" width) (when (some? gradient) - (let [gradient-id (dm/str "stroke-color-gradient_" render-id "_" index)] + (let [gradient-id (dm/str "stroke-color-gradient-" render-id "-" index)] (obj/set! attrs "stroke" (str/ffmt "url(#%)" gradient-id)))) (when-not (some? gradient) diff --git a/frontend/src/app/main/ui/shapes/custom_stroke.cljs b/frontend/src/app/main/ui/shapes/custom_stroke.cljs index 339a7e669..6a0f1255a 100644 --- a/frontend/src/app/main/ui/shapes/custom_stroke.cljs +++ b/frontend/src/app/main/ui/shapes/custom_stroke.cljs @@ -13,8 +13,10 @@ [app.common.geom.shapes.bounds :as gsb] [app.common.geom.shapes.text :as gst] [app.common.pages.helpers :as cph] + [app.config :as cfg] [app.main.ui.context :as muc] [app.main.ui.shapes.attrs :as attrs] + [app.main.ui.shapes.embed :as embed] [app.main.ui.shapes.gradients :as grad] [app.util.object :as obj] [cuerdas.core :as str] @@ -83,11 +85,18 @@ (let [id-prefix (dm/str "marker-" render-id) gradient (:stroke-color-gradient stroke) + image (:stroke-image stroke) cap-start (:stroke-cap-start stroke) cap-end (:stroke-cap-end stroke) - color (if (some? gradient) + color (cond + (some? gradient) (str/ffmt "url(#stroke-color-gradient-%s-%s)" render-id index) + + (some? image) + (str/ffmt "url(#stroke-fill-%-%)" render-id index) + + :else (:stroke-color stroke)) opacity (when-not (some? gradient) @@ -192,21 +201,57 @@ (mf/defc stroke-defs {::mf/wrap-props false} [{:keys [shape stroke render-id index]}] - (let [open-path? (and ^boolean (cph/path-shape? shape) - ^boolean (gsh/open-path? shape)) - gradient (:stroke-color-gradient stroke) - alignment (:stroke-alignment stroke :center) - width (:stroke-width stroke 0) + (let [open-path? (and ^boolean (cph/path-shape? shape) + ^boolean (gsh/open-path? shape)) + gradient (:stroke-color-gradient stroke) + alignment (:stroke-alignment stroke :center) + width (:stroke-width stroke 0) - props #js {:id (dm/str "stroke-color-gradient-" render-id "-" index) - :gradient gradient - :shape shape}] + props #js {:id (dm/str "stroke-color-gradient-" render-id "-" index) + :gradient gradient + :shape shape} + stroke-image (:stroke-image stroke) + uri (when stroke-image (cfg/resolve-file-media stroke-image)) + embed (embed/use-data-uris [uri]) + + stroke-width (case (:stroke-alignment stroke :center) + :center (/ (:stroke-width stroke 0) 2) + :outer (:stroke-width stroke 0) + 0) + margin (gsb/shape-stroke-margin stroke stroke-width) + + selrect (mf/with-memo [shape] + (if (cph/text-shape? shape) + (gst/shape->rect shape) + (grc/points->rect (:points shape)))) + + stroke-margin (+ stroke-width margin) + + w (+ (dm/get-prop selrect :width) (* 2 stroke-margin)) + h (+ (dm/get-prop selrect :height) (* 2 stroke-margin)) + image-props #js {:href (get embed uri uri) + :preserveAspectRatio "xMidYMid slice" + :width 1 + :height 1 + :id (dm/str "stroke-image-" render-id "-" index)}] [:* (when (some? gradient) (case (:type gradient) :linear [:> grad/linear-gradient props] :radial [:> grad/radial-gradient props])) + (when (:stroke-image stroke) + ;; We need to make the pattern size and the image fit so it's not repeated + [:pattern {:id (dm/str "stroke-fill-" render-id "-" index) + :patternContentUnits "objectBoundingBox" + :x (- (/ stroke-margin (dm/get-prop selrect :width))) + :y (- (/ stroke-margin (dm/get-prop selrect :height))) + :width (/ w (dm/get-prop selrect :width)) + :height (/ h (dm/get-prop selrect :height)) + :viewBox "0 0 1 1" + :preserveAspectRatio "xMidYMid slice"} + [:> :image image-props]]) + (cond (and (not open-path?) (= :inner alignment) @@ -345,6 +390,7 @@ index (unchecked-get props "index") render-id (mf/use-ctx muc/render-id) + render-id (d/nilv (unchecked-get props "render-id") render-id) stroke-width (:stroke-width stroke 0) stroke-style (:stroke-style stroke :none) @@ -385,7 +431,8 @@ url-fill? (or ^boolean (some? (:fill-image shape)) ^boolean (cph/image-shape? shape) ^boolean (> (count shape-fills) 1) - ^boolean (some? (some :fill-color-gradient shape-fills))) + ^boolean (some? (some :fill-color-gradient shape-fills)) + ^boolean (some? (some :fill-image shape-fills))) props (if (cph/frame-shape? shape) props @@ -447,6 +494,10 @@ (obj/set! "fillOpacity" "none") (obj/merge! (attrs/get-stroke-style value position render-id))) + style (if (:stroke-image value) + (obj/set! style "stroke" (dm/fmt "url(#stroke-fill-%-%)" render-id position)) + style) + props (-> (obj/clone props) (obj/unset! "fill") (obj/unset! "fillOpacity") diff --git a/frontend/src/app/main/ui/shapes/export.cljs b/frontend/src/app/main/ui/shapes/export.cljs index 9aa49313a..9ae5a0490 100644 --- a/frontend/src/app/main/ui/shapes/export.cljs +++ b/frontend/src/app/main/ui/shapes/export.cljs @@ -10,6 +10,7 @@ importation." (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.geom.shapes :as gsh] [app.common.svg :as csvg] [app.main.ui.context :as muc] @@ -282,36 +283,47 @@ (defn- export-fills-data [{:keys [fills]}] - (when-let [fills (seq fills)] - (mf/html - [:> "penpot:fills" #js {} - (for [[index fill] (d/enumerate fills)] - [:> "penpot:fill" - #js {:penpot:fill-color (if (some? (:fill-color-gradient fill)) - (str/format "url(#%s)" (str "fill-color-gradient_" (mf/use-ctx muc/render-id) "_" index)) - (d/name (:fill-color fill))) - :penpot:fill-color-ref-file (d/name (:fill-color-ref-file fill)) - :penpot:fill-color-ref-id (d/name (:fill-color-ref-id fill)) - :penpot:fill-opacity (d/name (:fill-opacity fill))}])]))) + (when-let [fills (seq fills)] + (let [render-id (mf/use-ctx muc/render-id)] + (mf/html + [:> "penpot:fills" #js {} + (for [[index fill] (d/enumerate fills)] + (let [fill-image-id (dm/str "fill-image-" render-id "-" index)] + [:> "penpot:fill" + #js {:penpot:fill-color (cond + (some? (:fill-color-gradient fill)) + (str/format "url(#%s)" (str "fill-color-gradient-" render-id "-" index)) + + :else + (d/name (:fill-color fill))) + :penpot:fill-image-id (when (:fill-image fill) fill-image-id) + :penpot:fill-color-ref-file (d/name (:fill-color-ref-file fill)) + :penpot:fill-color-ref-id (d/name (:fill-color-ref-id fill)) + :penpot:fill-opacity (d/name (:fill-opacity fill))}]))])))) (defn- export-strokes-data [{:keys [strokes]}] (when-let [strokes (seq strokes)] - (mf/html - [:> "penpot:strokes" #js {} - (for [[index stroke] (d/enumerate strokes)] - [:> "penpot:stroke" - #js {:penpot:stroke-color (if (some? (:stroke-color-gradient stroke)) - (str/format "url(#%s)" (str "stroke-color-gradient_" (mf/use-ctx muc/render-id) "_" index)) - (d/name (:stroke-color stroke))) - :penpot:stroke-color-ref-file (d/name (:stroke-color-ref-file stroke)) - :penpot:stroke-color-ref-id (d/name (:stroke-color-ref-id stroke)) - :penpot:stroke-opacity (d/name (:stroke-opacity stroke)) - :penpot:stroke-style (d/name (:stroke-style stroke)) - :penpot:stroke-width (d/name (:stroke-width stroke)) - :penpot:stroke-alignment (d/name (:stroke-alignment stroke)) - :penpot:stroke-cap-start (d/name (:stroke-cap-start stroke)) - :penpot:stroke-cap-end (d/name (:stroke-cap-end stroke))}])]))) + (let [render-id (mf/use-ctx muc/render-id)] + (mf/html + [:> "penpot:strokes" #js {} + (for [[index stroke] (d/enumerate strokes)] + (let [stroke-image-id (dm/str "stroke-image-" render-id "-" index)] + [:> "penpot:stroke" + #js {:penpot:stroke-color (cond + (some? (:stroke-color-gradient stroke)) + (str/format "url(#%s)" (str "stroke-color-gradient-" render-id "-" index)) + :else + (d/name (:stroke-color stroke))) + :penpot:stroke-image-id (when (:stroke-image stroke) stroke-image-id) + :penpot:stroke-color-ref-file (d/name (:stroke-color-ref-file stroke)) + :penpot:stroke-color-ref-id (d/name (:stroke-color-ref-id stroke)) + :penpot:stroke-opacity (d/name (:stroke-opacity stroke)) + :penpot:stroke-style (d/name (:stroke-style stroke)) + :penpot:stroke-width (d/name (:stroke-width stroke)) + :penpot:stroke-alignment (d/name (:stroke-alignment stroke)) + :penpot:stroke-cap-start (d/name (:stroke-cap-start stroke)) + :penpot:stroke-cap-end (d/name (:stroke-cap-end stroke))}]))])))) (defn- export-interactions-data [{:keys [interactions]}] (when-let [interactions (seq interactions)] @@ -461,5 +473,5 @@ (export-strokes-data shape) (export-grid-data shape) (export-layout-container-data shape) - (export-layout-item-data shape)])) + (export-layout-item-data shape)])) diff --git a/frontend/src/app/main/ui/shapes/fills.cljs b/frontend/src/app/main/ui/shapes/fills.cljs index 30a6556ac..9be7b7187 100644 --- a/frontend/src/app/main/ui/shapes/fills.cljs +++ b/frontend/src/app/main/ui/shapes/fills.cljs @@ -36,21 +36,26 @@ height (dm/get-prop selrect :height) has-image? (or (some? metadata) - (some? image)) + (some? image)) - uri (cond - (some? metadata) - (cfg/resolve-file-media metadata) + uri (cond + (some? metadata) + (cfg/resolve-file-media metadata) - (some? image) - (cfg/resolve-file-media image)) + (some? image) + (cfg/resolve-file-media image)) - embed (embed/use-data-uris [uri]) + uris (into [uri] + (comp + (keep :fill-image) + (map cfg/resolve-file-media)) + fills) + + embed (embed/use-data-uris uris) transform (gsh/transform-str shape) - ;; When tru e the image has not loaded yet - loading? (and (some? uri) - (not (contains? embed uri))) + ;; When true the image has not loaded yet + loading? (not-any? (partial contains? embed) uris) pat-props #js {:patternUnits "userSpaceOnUse" :x x @@ -65,10 +70,10 @@ (for [[shape-index shape] (d/enumerate (or (:position-data shape) [shape]))] [:* {:key (dm/str shape-index)} - (for [[fill-index value] (reverse (d/enumerate fills))] + (for [[fill-index value] (reverse (d/enumerate (get shape :fills [])))] (when (some? (:fill-color-gradient value)) (let [gradient (:fill-color-gradient value) - props #js {:id (dm/str "fill-color-gradient_" render-id "_" fill-index) + props #js {:id (dm/str "fill-color-gradient-" render-id "-" fill-index) :key (dm/str fill-index) :gradient gradient :shape shape}] @@ -84,13 +89,23 @@ (-> (obj/set! "width" (* width no-repeat-padding)) (obj/set! "height" (* height no-repeat-padding))))) [:g - (for [[fill-index value] (reverse (d/enumerate fills))] + (for [[fill-index value] (reverse (d/enumerate (get shape :fills [])))] (let [style (attrs/get-fill-style value fill-index render-id type) props #js {:key (dm/str fill-index) :width width :height height :style style}] - [:> :rect props])) + (if (:fill-image value) + (let [uri (cfg/resolve-file-media (:fill-image value)) + image-props #js {:id (dm/str "fill-image-" render-id "-" fill-index) + :href (get embed uri uri) + :preserveAspectRatio "xMidYMid slice" + :width width + :height height + :key (dm/str fill-index) + :opacity (:fill-opacity value)}] + [:> :image image-props]) + [:> :rect props]))) (when ^boolean has-image? [:g @@ -121,5 +136,6 @@ (or (= type :image) (= type :text)) (> (count fills) 1) - (some :fill-color-gradient fills)) + (some :fill-color-gradient fills) + (some :fill-image fills)) [:> fills* props]))) diff --git a/frontend/src/app/main/ui/shapes/gradients.cljs b/frontend/src/app/main/ui/shapes/gradients.cljs index 2eb3b16cc..0cbea5950 100644 --- a/frontend/src/app/main/ui/shapes/gradients.cljs +++ b/frontend/src/app/main/ui/shapes/gradients.cljs @@ -125,7 +125,7 @@ id (if (some? id) id - (dm/str (name attr) "_" rid)) + (dm/str (name attr) "-" rid)) gradient (get shape attr) props #js {:id id diff --git a/frontend/src/app/main/ui/shapes/text/svg_text.cljs b/frontend/src/app/main/ui/shapes/text/svg_text.cljs index dfaaa560f..b608263cf 100644 --- a/frontend/src/app/main/ui/shapes/text/svg_text.cljs +++ b/frontend/src/app/main/ui/shapes/text/svg_text.cljs @@ -54,7 +54,7 @@ (attrs/add-border-props! shape)) get-gradient-id (fn [index] - (str render-id "_" (:id shape) "_" index))] + (str render-id "-" (:id shape) "-" index))] [:* ;; Definition of gradients for partial elements @@ -62,7 +62,7 @@ [:defs (for [[index data] (d/enumerate position-data)] (when (some? (:fill-color-gradient data)) - (let [id (dm/str "fill-color-gradient_" (get-gradient-id index))] + (let [id (dm/str "fill-color-gradient-" (get-gradient-id index))] [:& grad/gradient {:id id :key id :attr :fill-color-gradient @@ -99,6 +99,6 @@ (obj/merge! browser-props))) shape (assoc shape :fills (:fills data))] - [:& (mf/provider muc/render-id) {:key index :value (str render-id "_" (:id shape) "_" index)} + [:& (mf/provider muc/render-id) {:key index :value render-id} [:& shape-custom-strokes {:shape shape :position index :render-id render-id} [:> :text props (:text data)]]]))]])) diff --git a/frontend/src/app/main/ui/viewer/inspect/attributes/common.cljs b/frontend/src/app/main/ui/viewer/inspect/attributes/common.cljs index 7b2cc13b8..9fc7e6957 100644 --- a/frontend/src/app/main/ui/viewer/inspect/attributes/common.cljs +++ b/frontend/src/app/main/ui/viewer/inspect/attributes/common.cljs @@ -8,6 +8,8 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.colors :as cc] + [app.common.media :as cm] + [app.config :as cf] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.color-bullet :refer [color-bullet color-name]] @@ -49,36 +51,64 @@ colors-library (get-colors-library color) file-colors (get-file-colors) color-library-name (get-in (or colors-library file-colors) [(:id color) :name]) - color (assoc color :color-library-name color-library-name)] + color (assoc color :color-library-name color-library-name) + image (:image color)] (if new-css-system - [:div {:class (stl/css :attributes-color-row)} - [:div {:class (stl/css :bullet-wrapper) - :style #js {"--bullet-size" "16px"}} - [:& cbn/color-bullet {:color color - :mini? true}]] + [:* + [:div {:class (stl/css :attributes-color-row)} + [:div {:class (stl/css :bullet-wrapper) + :style #js {"--bullet-size" "16px"}} + [:& cbn/color-bullet {:color color + :mini? true}]] - [:div {:class (stl/css :format-wrapper)} - (when-not (and on-change-format (:gradient color)) + (when-not image + [:div {:class (stl/css :format-wrapper)} + (when-not (and on-change-format (or (:gradient color) image)) + [:div {:class (stl/css :select-format-wrapper)} + [:& select + {:default-value format + :options [{:value :hex :label (tr "inspect.attributes.color.hex")} + {:value :rgba :label (tr "inspect.attributes.color.rgba")} + {:value :hsla :label (tr "inspect.attributes.color.hsla")}] + :on-change on-change-format}]]) + (when (:gradient color) + [:div {:class (stl/css :format-info)} "rgba"])]) - [:div {:class (stl/css :select-format-wrapper)} - [:& select - {:default-value format - :options [{:value :hex :label (tr "inspect.attributes.color.hex")} - {:value :rgba :label (tr "inspect.attributes.color.rgba")} - {:value :hsla :label (tr "inspect.attributes.color.hsla")}] - :on-change on-change-format}]]) - (when (:gradient color) - [:div {:class (stl/css :format-info)} "rgba"])] + (if (and copy-data (not image)) + [:& copy-button {:data copy-data + :class (stl/css :color-row-copy-btn)} + [:* + [:div {:class (stl/css :first-row)} + [:div {:class (stl/css :name-opacity)} + [:span {:class (stl/css-case :color-value-wrapper true + :gradient-name (:gradient color))} + (if (:gradient color) + [:& cbn/color-name {:color color + :size 80}] + (case format + :hex [:& cbn/color-name {:color color + :size 80}] + :rgba (let [[r g b a] (cc/hex->rgba (:color color) (:opacity color))] + [:* (str/fmt "%s, %s, %s, %s" r g b a)]) + :hsla (let [[h s l a] (cc/hex->hsla (:color color) (:opacity color)) + result (cc/format-hsla [h s l a])] + [:* result])))] - (if copy-data - [:& copy-button {:data copy-data - :class (stl/css :color-row-copy-btn)} - [:* + (when-not (:gradient color) + [:span {:class (stl/css :opacity-info)} + (str (* 100 (:opacity color)) "%")])]] + + (when color-library-name + [:div {:class (stl/css :second-row)} + [:div {:class (stl/css :color-name-library)} + color-library-name]])]] + + [:div {:class (stl/css :color-info)} [:div {:class (stl/css :first-row)} [:div {:class (stl/css :name-opacity)} [:span {:class (stl/css-case :color-value-wrapper true - :gradient-name (:gradient color))} + :gradient-name (:gradient color))} (if (:gradient color) [:& cbn/color-name {:color color :size 80}] @@ -98,90 +128,66 @@ (when color-library-name [:div {:class (stl/css :second-row)} [:div {:class (stl/css :color-name-library)} - color-library-name]])]] + color-library-name]])])] - [:div {:class (stl/css :color-info)} - [:div {:class (stl/css :first-row)} - [:div {:class (stl/css :name-opacity)} - [:span {:class (stl/css-case :color-value-wrapper true - :gradient-name (:gradient color))} - (if (:gradient color) - [:& cbn/color-name {:color color - :size 80}] - (case format - :hex [:& cbn/color-name {:color color - :size 80}] - :rgba (let [[r g b a] (cc/hex->rgba (:color color) (:opacity color))] - [:* (str/fmt "%s, %s, %s, %s" r g b a)]) - :hsla (let [[h s l a] (cc/hex->hsla (:color color) (:opacity color)) - result (cc/format-hsla [h s l a])] - [:* result])))] + (when image + (let [mtype (-> image :mtype) + name (or (:name image) (tr "media.image")) + extension (cm/mtype->extension mtype)] + [:a {:class (stl/css :download-button) + :target "_blank" + :download (cond-> name extension (str/concat extension)) + :href (cf/resolve-file-media image)} + (tr "inspect.attributes.image.download")]))] - (when-not (:gradient color) - [:span {:class (stl/css :opacity-info)} - (str (* 100 (:opacity color)) "%")])]] + [:* + [:div.attributes-color-row + (when color-library-name + [:div.attributes-color-id + [:& color-bullet {:color color}] + [:div color-library-name]]) - (when color-library-name - [:div {:class (stl/css :second-row)} - [:div {:class (stl/css :color-name-library)} - color-library-name]]) - ;; [:span {:class (stl/css-case :color-name-wrapper true - ;; :gradient-color (:gradient color))} + [:div.attributes-color-value {:class (when color-library-name "hide-color")} + [:& color-bullet {:color color}] - ;; [:div {:class (stl/css :color-value-wrapper)} - ;; (if (:gradient color) - ;; [:& cbn/color-name {:color color - ;; :size 80}] - ;; (case format - ;; :hex [:& cbn/color-name {:color color - ;; :size 80}] - ;; :rgba (let [[r g b a] (cc/hex->rgba (:color color) (:opacity color))] - ;; [:* (str/fmt "%s, %s, %s, %s" r g b a)]) - ;; :hsla (let [[h s l a] (cc/hex->hsla (:color color) (:opacity color)) - ;; result (cc/format-hsla [h s l a])] - ;; [:* result])))] + (cond + (:gradient color) + [:& color-name {:color color}] - ;; (when color-library-name - ;; [:div {:class (stl/css :color-name-library)} - ;; color-library-name])] + (= format :rgba) + (let [[r g b a] (cc/hex->rgba (:color color) (:opacity color))] + [:div (str/fmt "%s, %s, %s, %s" r g b a)]) - ;; (when-not (:gradient color) - ;; [:div {:class (stl/css :opacity-info)} - ;; (str (* 100 (:opacity color)) "%")]) - ])] + (= format :hsla) + (let [[h s l a] (cc/hex->hsla (:color color) (:opacity color)) + result (cc/format-hsla [h s l a])] + [:div result]) + :else + [:* + [:& color-name {:color color}] + (when-not (:gradient color) [:div (str (* 100 (:opacity color)) "%")])]) - [:div.attributes-color-row - (when color-library-name - [:div.attributes-color-id - [:& color-bullet {:color color}] - [:div color-library-name]]) + (when-not (and on-change-format (or (:gradient color) image)) + [:select.color-format-select {:on-change #(-> (dom/get-target-val %) keyword on-change-format)} + [:option {:value "hex"} + (tr "inspect.attributes.color.hex")] - [:div.attributes-color-value {:class (when color-library-name "hide-color")} - [:& color-bullet {:color color}] + [:option {:value "rgba"} + (tr "inspect.attributes.color.rgba")] - (if (:gradient color) - [:& color-name {:color color}] - (case format - :rgba (let [[r g b a] (cc/hex->rgba (:color color) (:opacity color))] - [:div (str/fmt "%s, %s, %s, %s" r g b a)]) - :hsla (let [[h s l a] (cc/hex->hsla (:color color) (:opacity color)) - result (cc/format-hsla [h s l a])] - [:div result]) - [:* - [:& color-name {:color color}] - (when-not (:gradient color) [:div (str (* 100 (:opacity color)) "%")])])) + [:option {:value "hsla"} + (tr "inspect.attributes.color.hsla")]])] - (when-not (and on-change-format (:gradient color)) - [:select.color-format-select {:on-change #(-> (dom/get-target-val %) keyword on-change-format)} - [:option {:value "hex"} - (tr "inspect.attributes.color.hex")] + (when (and copy-data (not image)) + [:& copy-button {:data copy-data}])] - [:option {:value "rgba"} - (tr "inspect.attributes.color.rgba")] - - [:option {:value "hsla"} - (tr "inspect.attributes.color.hsla")]])] - (when copy-data - [:& copy-button {:data copy-data}])]))) + (when image + (let [mtype (-> image :mtype) + name (or (:name image) (tr "media.image")) + extension (cm/mtype->extension mtype)] + [:a.download-button {:target "_blank" + :download (cond-> name extension (str/concat extension)) + :href (cf/resolve-file-media image)} + (tr "inspect.attributes.image.download")]))]))) diff --git a/frontend/src/app/main/ui/viewer/inspect/attributes/common.scss b/frontend/src/app/main/ui/viewer/inspect/attributes/common.scss index 94e960cff..709ca251e 100644 --- a/frontend/src/app/main/ui/viewer/inspect/attributes/common.scss +++ b/frontend/src/app/main/ui/viewer/inspect/attributes/common.scss @@ -72,7 +72,6 @@ @include titleTipography; color: var(--menu-foreground-color); padding: $s-8 0; - height: $s-32; } button { @@ -129,3 +128,10 @@ } } } + +.download-button { + @extend .button-secondary; + @include tabTitleTipography; + height: $s-32; + margin-top: $s-4; +} diff --git a/frontend/src/app/main/ui/viewer/inspect/attributes/fill.cljs b/frontend/src/app/main/ui/viewer/inspect/attributes/fill.cljs index e0982479a..13667f38a 100644 --- a/frontend/src/app/main/ui/viewer/inspect/attributes/fill.cljs +++ b/frontend/src/app/main/ui/viewer/inspect/attributes/fill.cljs @@ -21,7 +21,8 @@ :opacity (:fill-opacity shape) :gradient (:fill-color-gradient shape) :id (:fill-color-ref-id shape) - :file-id (:fill-color-ref-file shape)}) + :file-id (:fill-color-ref-file shape) + :image (:fill-image shape)}) (defn has-fill? [shape] (and diff --git a/frontend/src/app/main/ui/viewer/inspect/attributes/stroke.cljs b/frontend/src/app/main/ui/viewer/inspect/attributes/stroke.cljs index 07fe6bb51..707c4d054 100644 --- a/frontend/src/app/main/ui/viewer/inspect/attributes/stroke.cljs +++ b/frontend/src/app/main/ui/viewer/inspect/attributes/stroke.cljs @@ -25,7 +25,8 @@ :opacity (:stroke-opacity shape) :gradient (:stroke-color-gradient shape) :id (:stroke-color-ref-id shape) - :file-id (:stroke-color-ref-file shape)}) + :file-id (:stroke-color-ref-file shape) + :image (:stroke-image shape)}) (defn has-stroke? [shape] (seq (:strokes shape))) diff --git a/frontend/src/app/main/ui/viewer/inspect/attributes/text.cljs b/frontend/src/app/main/ui/viewer/inspect/attributes/text.cljs index 0bb48d41c..2cdc2a134 100644 --- a/frontend/src/app/main/ui/viewer/inspect/attributes/text.cljs +++ b/frontend/src/app/main/ui/viewer/inspect/attributes/text.cljs @@ -35,12 +35,13 @@ (get-in state [:viewer-libraries file-id :data :typographies]))] #(l/derived get-library st/state))) -(defn fill->color [{:keys [fill-color fill-opacity fill-color-gradient fill-color-ref-id fill-color-ref-file]}] +(defn fill->color [{:keys [fill-color fill-opacity fill-color-gradient fill-color-ref-id fill-color-ref-file fill-image]}] {:color fill-color :opacity fill-opacity :gradient fill-color-gradient :id fill-color-ref-id - :file-id fill-color-ref-file}) + :file-id fill-color-ref-file + :image fill-image}) (defn copy-style-data [style & properties] diff --git a/frontend/src/app/main/ui/viewer/inspect/right_sidebar.cljs b/frontend/src/app/main/ui/viewer/inspect/right_sidebar.cljs index 90a0c155a..bf42b3e7b 100644 --- a/frontend/src/app/main/ui/viewer/inspect/right_sidebar.cljs +++ b/frontend/src/app/main/ui/viewer/inspect/right_sidebar.cljs @@ -11,6 +11,7 @@ [app.common.types.component :as ctk] [app.main.refs :as refs] [app.main.ui.components.shape-icon :as si] + [app.main.ui.components.shape-icon-refactor :as sir] [app.main.ui.components.tab-container :refer [tab-container tab-element]] [app.main.ui.components.tabs-container :refer [tabs-container tabs-element]] [app.main.ui.context :as ctx] @@ -97,7 +98,7 @@ [:span {:class (stl/css :layer-title)} (tr "inspect.tabs.code.selected.multiple" (count shapes))]] [:* [:span {:class (stl/css :shape-icon)} - [:& si/element-icon-refactor {:shape first-shape :main-instance? main-instance?}]] + [:& sir/element-icon-refactor {:shape first-shape :main-instance? main-instance?}]] ;; Execution time translation strings: ;; inspect.tabs.code.selected.circle ;; inspect.tabs.code.selected.component diff --git a/frontend/src/app/main/ui/workspace/color_palette.cljs b/frontend/src/app/main/ui/workspace/color_palette.cljs index b4db61613..8a191c763 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette.cljs @@ -38,7 +38,7 @@ (mf/defc palette [{:keys [current-colors size width]}] (let [;; We had to do this due to a bug that leave some bugged colors - current-colors (h/use-equal-memo (filter #(or (:gradient %) (:color %)) current-colors)) + current-colors (h/use-equal-memo (filter #(or (:gradient %) (:color %) (:image %)) current-colors)) state (mf/use-state {:show-menu false}) offset-step (cond (<= size 64) 40 diff --git a/frontend/src/app/main/ui/workspace/colorpalette.cljs b/frontend/src/app/main/ui/workspace/colorpalette.cljs index c0f5beb6c..fc8ce9ed8 100644 --- a/frontend/src/app/main/ui/workspace/colorpalette.cljs +++ b/frontend/src/app/main/ui/workspace/colorpalette.cljs @@ -37,7 +37,7 @@ (mf/defc palette [{:keys [current-colors recent-colors file-colors shared-libs selected on-select]}] (let [;; We had to do this due to a bug that leave some bugged colors - current-colors (h/use-equal-memo (filter #(or (:gradient %) (:color %)) current-colors)) + current-colors (h/use-equal-memo (filter #(or (:gradient %) (:color %) (:image %)) current-colors)) state (mf/use-state {:show-menu false}) width (:width @state 0) diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index c9e1dccab..3dab5d7be 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -8,12 +8,17 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.colors :as cc] + [app.common.data :as d] + [app.config :as cfg] [app.main.data.modal :as modal] [app.main.data.workspace.colors :as dc] [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.media :as dwm] [app.main.data.workspace.undo :as dwu] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.components.file-uploader :refer [file-uploader]] + [app.main.ui.components.select :refer [select]] [app.main.ui.components.tab-container :refer [tab-container tab-element]] [app.main.ui.context :as ctx] [app.main.ui.icons :as i] @@ -46,22 +51,50 @@ ;; --- Color Picker Modal (mf/defc colorpicker - [{:keys [data disable-gradient disable-opacity on-change on-accept]}] - (let [new-css-system (mf/use-ctx ctx/new-css-system) - state (mf/deref refs/colorpicker) - node-ref (mf/use-ref) + [{:keys [data disable-gradient disable-opacity disable-image on-change on-accept]}] + (let [new-css-system (mf/use-ctx ctx/new-css-system) + state (mf/deref refs/colorpicker) + node-ref (mf/use-ref) ;; TODO: I think we need to put all this picking state under ;; the same object for avoid creating adhoc refs for each ;; value - picking-color? (mf/deref picking-color?) - picked-color (mf/deref picked-color) - picked-color-select (mf/deref picked-color-select) + picking-color? (mf/deref picking-color?) + picked-color (mf/deref picked-color) + picked-color-select (mf/deref picked-color-select) - current-color (:current-color state) + current-color (:current-color state) - active-tab (mf/use-state (dc/get-active-color-tab)) - drag? (mf/use-state false) + active-fill-tab (if (:image data) + :image + (if-let [gradient (:gradient data)] + (case (:type gradient) + :linear :linear-gradient + :radial :radial-gradient) + :color)) + active-color-tab (mf/use-state (dc/get-active-color-tab)) + drag? (mf/use-state false) + + fill-image-ref (mf/use-ref nil) + + selected-mode (get state :type :color) + + disabled-color-accept? (and + (= selected-mode :image) + (not (:image current-color))) + + on-fill-image-success + (mf/use-fn + (fn [image] + (st/emit! (dc/update-colorpicker-color {:image (select-keys image [:id :width :height :mtype :name])} (not @drag?))))) + + on-fill-image-click + (mf/use-callback #(dom/click (mf/ref-val fill-image-ref))) + + on-fill-image-selected + (mf/use-fn + (fn [file] + (st/emit! (dwm/upload-fill-image file on-fill-image-success)))) set-tab! (mf/use-fn @@ -69,9 +102,18 @@ (let [tab (-> (dom/get-current-target event) (dom/get-data "tab") (keyword))] - (reset! active-tab tab) + (reset! active-color-tab tab) (dc/set-active-color-tab! tab)))) + handle-change-mode + (mf/use-fn + (fn [value] + (case value + :color (st/emit! (dc/activate-colorpicker-color)) + :linear-gradient (st/emit! (dc/activate-colorpicker-gradient :linear-gradient)) + :radial-gradient (st/emit! (dc/activate-colorpicker-gradient :radial-gradient)) + :image (st/emit! (dc/activate-colorpicker-image))))) + handle-change-color (mf/use-fn (mf/deps current-color @drag?) @@ -105,7 +147,7 @@ on-select-library-color (mf/use-fn (fn [state color] - (let [type-origin (:type state) + (let [type-origin selected-mode editig-stop-origin (:editing-stop state) is-gradient? (some? (:gradient color)) change-to (fn [new-color] @@ -149,12 +191,6 @@ (fn [_] (st/emit! (dwl/add-color (dc/get-color-from-colorpicker-state state))))) - on-activate-linear-gradient - (mf/use-fn #(st/emit! (dc/activate-colorpicker-gradient :linear-gradient))) - - on-activate-radial-gradient - (mf/use-fn #(st/emit! (dc/activate-colorpicker-gradient :radial-gradient))) - on-start-drag (mf/use-fn (mf/deps drag? node-ref) @@ -174,11 +210,21 @@ (mf/deps state) (fn [] (on-accept (dc/get-color-from-colorpicker-state state)) - (modal/hide!)))] + (modal/hide!))) + + options + (mf/with-memo [selected-mode disable-gradient disable-image] + (d/concat-vec + [{:value :color :label (tr "media.solid")}] + (when (not disable-gradient) + [{:value :linear-gradient :label (tr "media.linear")} + {:value :radial-gradient :label (tr "media.radial")}]) + (when (not disable-image) + [{:value :image :label (tr "media.image")}])))] ;; Initialize colorpicker state (mf/with-effect [] - (st/emit! (dc/initialize-colorpicker on-change)) + (st/emit! (dc/initialize-colorpicker on-change active-fill-tab)) (partial st/emit! (dc/finalize-colorpicker))) ;; Update colorpicker with external color changes @@ -220,181 +266,219 @@ :ref node-ref :style {:touch-action "none"}} [:div {:class (stl/css :top-actions)} - [:button {:class (stl/css-case :picker-btn true - :selected picking-color?) - :on-click handle-click-picker} - i/picker-refactor] - (when (not disable-gradient) - [:div {:class (stl/css :gradient-buttons)} - [:button - {:on-click on-activate-linear-gradient - :class (stl/css-case :gradient-btn true - :linear-gradient-btn true - :selected (= :linear-gradient (:type state)))}] + (when (or (not disable-gradient) (not disable-image)) + [:div {:class (stl/css :select)} + [:& select + {:default-value selected-mode + :options options + :on-change handle-change-mode}]]) + (when (not= selected-mode :image) + [:button {:class (stl/css-case :picker-btn true + :selected picking-color?) + :on-click handle-click-picker} + i/picker-refactor])] - [:button - {:on-click on-activate-radial-gradient - :class (stl/css-case :gradient-btn true - :radial-gradient-btn true - :selected (= :radial-gradient (:type state)))}]])] - - (when (or (= (:type state) :linear-gradient) - (= (:type state) :radial-gradient)) + (when (or (= selected-mode :linear-gradient) + (= selected-mode :radial-gradient)) [:& gradients {:stops (:stops state) :editing-stop (:editing-stop state) :on-select-stop handle-change-stop}]) - [:div {:class (stl/css :colorpicker-tabs)} - [:& tab-container - {:on-change-tab set-tab! - :selected @active-tab - :collapsable? false} + (if (= selected-mode :image) + (let [uri (cfg/resolve-file-media (:image current-color))] + [:div {:class (stl/css :select-image)} + [:div {:class (stl/css :content)} + (when (:image current-color) + [:img {:src uri}])] + [:button + {:class (stl/css :choose-image) + :title (tr "media.choose-image") + :aria-label (tr "media.choose-image") + :on-click on-fill-image-click} + (tr "media.choose-image") + [:& file-uploader + {:input-id "fill-image-upload" + :accept "image/jpeg,image/png" + :multi false + :ref fill-image-ref + :on-selected on-fill-image-selected}]]]) + [:* + [:div {:class (stl/css :colorpicker-tabs)} + [:& tab-container + {:on-change-tab set-tab! + :selected @active-color-tab + :collapsable? false} - [:& tab-element {:id :ramp :title i/rgba-refactor} - (if picking-color? - [:div {:class (stl/css :picker-detail-wrapper)} - [:div {:class (stl/css :center-circle)}] - [:canvas#picker-detail {:width 256 :height 140}]] - [:& ramp-selector - {:color current-color - :disable-opacity disable-opacity - :on-change handle-change-color - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}])] + [:& tab-element {:id :ramp :title i/rgba-refactor} + (if picking-color? + [:div {:class (stl/css :picker-detail-wrapper)} + [:div {:class (stl/css :center-circle)}] + [:canvas#picker-detail {:width 256 :height 140}]] + [:& ramp-selector + {:color current-color + :disable-opacity disable-opacity + :on-change handle-change-color + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}])] - [:& tab-element {:id :harmony :title i/rgba-complementary-refactor} - (if picking-color? - [:div {:class (stl/css :picker-detail-wrapper)} - [:div {:class (stl/css :center-circle)}] - [:canvas#picker-detail {:width 256 :height 140}]] - [:& harmony-selector - {:color current-color - :disable-opacity disable-opacity - :on-change handle-change-color - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}])] + [:& tab-element {:id :harmony :title i/rgba-complementary-refactor} + (if picking-color? + [:div {:class (stl/css :picker-detail-wrapper)} + [:div {:class (stl/css :center-circle)}] + [:canvas#picker-detail {:width 256 :height 140}]] + [:& harmony-selector + {:color current-color + :disable-opacity disable-opacity + :on-change handle-change-color + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}])] - [:& tab-element {:id :hsva :title i/hsva-refactor} - (if picking-color? - [:div {:class (stl/css :picker-detail-wrapper)} - [:div {:class (stl/css :center-circle)}] - [:canvas#picker-detail {:width 256 :height 140}]] - [:& hsva-selector - {:color current-color - :disable-opacity disable-opacity - :on-change handle-change-color - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}])]]] + [:& tab-element {:id :hsva :title i/hsva-refactor} + (if picking-color? + [:div {:class (stl/css :picker-detail-wrapper)} + [:div {:class (stl/css :center-circle)}] + [:canvas#picker-detail {:width 256 :height 140}]] + [:& hsva-selector + {:color current-color + :disable-opacity disable-opacity + :on-change handle-change-color + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}])]]] - [:& color-inputs - {:type (if (= @active-tab :hsva) :hsv :rgb) - :disable-opacity disable-opacity - :color current-color - :on-change handle-change-color}] + [:& color-inputs + {:type (if (= @active-color-tab :hsva) :hsv :rgb) + :disable-opacity disable-opacity + :color current-color + :on-change handle-change-color}] - [:& libraries - {:state state - :current-color current-color - :on-select-color on-select-library-color - :on-add-library-color on-add-library-color}] + [:& libraries + {:state state + :current-color current-color + :disable-gradient disable-gradient + :disable-opacity disable-opacity + :disable-image disable-image + :on-select-color on-select-library-color + :on-add-library-color on-add-library-color}]]) (when on-accept [:div {:class (stl/css :actions)} - [:button {:class (stl/css :accept-color) - :on-click on-color-accept} + [:button {:class (stl/css-case + :accept-color true + :btn-disabled disabled-color-accept?) + :on-click on-color-accept + :disabled disabled-color-accept?} (tr "workspace.libraries.colors.save-color")]])] [:div.colorpicker {:ref node-ref :style {:touch-action "none"}} [:div.colorpicker-content [:div.top-actions - [:button.picker-btn - {:class (when picking-color? "active") - :on-click handle-click-picker} - i/picker] - - (when (not disable-gradient) - [:div.gradients-buttons - [:button.gradient.linear-gradient - {:on-click on-activate-linear-gradient - :class (when (= :linear-gradient (:type state)) "active")}] - - [:button.gradient.radial-gradient - {:on-click on-activate-radial-gradient - :class (when (= :radial-gradient (:type state)) "active")}]])] + (when (or (not disable-gradient) (not disable-image)) + [:div.element-set-content + [:& select + {:default-value selected-mode + :options options + :on-change handle-change-mode}]]) + (when (not= selected-mode :image) + [:button.picker-btn + {:class (when picking-color? "active") + :on-click handle-click-picker} + i/picker])] (when (or (= (:type state) :linear-gradient) - (= (:type state) :radial-gradient)) + (= (:type state) :radial-gradient)) [:& gradients {:stops (:stops state) :editing-stop (:editing-stop state) :on-select-stop handle-change-stop}]) - [:div.colorpicker-tabs - [:div.colorpicker-tab.tooltip.tooltip-bottom.tooltip-expand - {:class (when (= @active-tab :ramp) "active") - :alt (tr "workspace.libraries.colors.rgba") - :on-click set-tab! - :data-tab "ramp"} i/picker-ramp] - [:div.colorpicker-tab.tooltip.tooltip-bottom.tooltip-expand - {:class (when (= @active-tab :harmony) "active") - :alt (tr "workspace.libraries.colors.rgb-complementary") - :on-click set-tab! - :data-tab "harmony"} i/picker-harmony] - [:div.colorpicker-tab.tooltip.tooltip-bottom.tooltip-expand - {:class (when (= @active-tab :hsva) "active") - :alt (tr "workspace.libraries.colors.hsv") - :on-click set-tab! - :data-tab "hsva"} i/picker-hsv]] + (if (= selected-mode :image) + (let [uri (cfg/resolve-file-media (:image current-color))] + [:div.select-image + [:div.content + (when (:image current-color) + [:img {:src uri}])] + [:button.btn-secondary + {:title (tr "media.choose-image") + :aria-label (tr "media.choose-image") + :on-click on-fill-image-click} + (tr "media.choose-image") + [:& file-uploader + {:input-id "fill-image-upload" + :accept "image/jpeg,image/png" + :multi false + :ref fill-image-ref + :on-selected on-fill-image-selected}]]]) + [:* + [:div.colorpicker-tabs + [:div.colorpicker-tab.tooltip.tooltip-bottom.tooltip-expand + {:class (when (= @active-color-tab :ramp) "active") + :alt (tr "workspace.libraries.colors.rgba") + :on-click set-tab! + :data-tab "ramp"} i/picker-ramp] + [:div.colorpicker-tab.tooltip.tooltip-bottom.tooltip-expand + {:class (when (= @active-color-tab :harmony) "active") + :alt (tr "workspace.libraries.colors.rgb-complementary") + :on-click set-tab! + :data-tab "harmony"} i/picker-harmony] + [:div.colorpicker-tab.tooltip.tooltip-bottom.tooltip-expand + {:class (when (= @active-color-tab :hsva) "active") + :alt (tr "workspace.libraries.colors.hsv") + :on-click set-tab! + :data-tab "hsva"} i/picker-hsv]] - (if picking-color? - [:div.picker-detail-wrapper - [:div.center-circle] - [:canvas#picker-detail {:width 200 :height 160}]] - (case @active-tab - :ramp - [:& ramp-selector - {:color current-color - :disable-opacity disable-opacity - :on-change handle-change-color - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}] - :harmony - [:& harmony-selector - {:color current-color - :disable-opacity disable-opacity - :on-change handle-change-color - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}] - :hsva - [:& hsva-selector - {:color current-color - :disable-opacity disable-opacity - :on-change handle-change-color - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}] - nil)) + (if picking-color? + [:div.picker-detail-wrapper + [:div.center-circle] + [:canvas#picker-detail {:width 200 :height 160}]] + (case @active-color-tab + :ramp + [:& ramp-selector + {:color current-color + :disable-opacity disable-opacity + :on-change handle-change-color + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] + :harmony + [:& harmony-selector + {:color current-color + :disable-opacity disable-opacity + :on-change handle-change-color + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] + :hsva + [:& hsva-selector + {:color current-color + :disable-opacity disable-opacity + :on-change handle-change-color + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] + nil)) - [:& color-inputs - {:type (if (= @active-tab :hsva) :hsv :rgb) - :disable-opacity disable-opacity - :color current-color - :on-change handle-change-color}] + [:& color-inputs + {:type (if (= @active-color-tab :hsva) :hsv :rgb) + :disable-opacity disable-opacity + :color current-color + :on-change handle-change-color}] - [:& libraries - {:state state - :current-color current-color - :disable-gradient disable-gradient - :disable-opacity disable-opacity - :on-select-color on-select-library-color - :on-add-library-color on-add-library-color}] + [:& libraries + {:state state + :current-color current-color + :disable-gradient disable-gradient + :disable-opacity disable-opacity + :disable-image disable-image + :on-select-color on-select-library-color + :on-add-library-color on-add-library-color}]]) (when on-accept [:div.actions [:button.btn-primary.btn-large - {:on-click on-color-accept} + {:on-click on-color-accept + :disabled disabled-color-accept? + :class (dom/classnames + :btn-disabled disabled-color-accept?)} (tr "workspace.libraries.colors.save-color")]])]]))) (defn calculate-position @@ -420,6 +504,7 @@ [{:keys [x y data position disable-gradient disable-opacity + disable-image on-change on-close on-accept] :as props}] (let [new-css-system (mf/use-ctx ctx/new-css-system) vport (mf/deref viewport) @@ -445,6 +530,7 @@ [:& colorpicker {:data data :disable-gradient disable-gradient :disable-opacity disable-opacity + :disable-image disable-image :on-change handle-change :on-accept on-accept}]])) diff --git a/frontend/src/app/main/ui/workspace/colorpicker.scss b/frontend/src/app/main/ui/workspace/colorpicker.scss index f030e5a39..ae18f6abd 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker.scss @@ -20,8 +20,9 @@ .top-actions { display: flex; align-items: flex-start; + flex-direction: row-reverse; justify-content: space-between; - height: $s-28; + height: $s-40; .picker-btn { @include buttonStyle; @include flexCenter; @@ -32,6 +33,7 @@ width: $s-20; border-radius: $br-4; padding: 0; + margin-top: $s-4; svg { @extend .button-icon; stroke: var(--button-tertiary-foreground-color-rest); @@ -96,7 +98,7 @@ display: flex; gap: $s-4; .accept-color { - @include titleTipography; + @include tabTitleTipography; @extend .button-secondary; width: 100%; height: $s-32; @@ -122,3 +124,36 @@ } } } + +.select { + width: $s-116; +} + +.select-image { + margin-top: $s-4; + .content { + border-radius: $br-8; + display: flex; + justify-content: center; + background-image: url("/images/colorpicker-no-image.png"); + background-position: center; + background-size: auto $s-140; + height: $s-140; + margin-bottom: $s-6; + margin-right: $s-1; + img { + height: fit-content; + width: fit-content; + max-height: 100%; + max-width: 100%; + margin: auto; + } + } + .choose-image { + @extend .button-secondary; + @include tabTitleTipography; + width: 100%; + margin-top: $s-12; + height: $s-32; + } +} diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs index 7a6690eba..d184ba8b4 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.cljs @@ -27,7 +27,7 @@ [rumext.v2 :as mf])) (mf/defc libraries - [{:keys [state on-select-color on-add-library-color disable-gradient disable-opacity]}] + [{:keys [state on-select-color on-add-library-color disable-gradient disable-opacity disable-image]}] (let [new-css-system (mf/use-ctx ctx/new-css-system) selected (h/use-shared-state mdc/colorpicker-selected-broadcast-key :recent) current-colors (mf/use-state []) @@ -35,7 +35,7 @@ shared-libs (mf/deref refs/workspace-libraries) file-colors (mf/deref refs/workspace-file-colors) recent-colors (mf/deref refs/workspace-recent-colors) - recent-colors (h/use-equal-memo (filter #(or (:gradient %) (:color %)) recent-colors)) + recent-colors (h/use-equal-memo (filter #(or (:gradient %) (:color %) (:image %)) recent-colors)) on-library-change (mf/use-fn @@ -52,7 +52,8 @@ check-valid-color? (fn [color] (and (or (not disable-gradient) (not (:gradient color))) - (or (not disable-opacity) (= 1 (:opacity color))))) + (or (not disable-opacity) (= 1 (:opacity color))) + (or (not disable-image) (not (:image color))))) toggle-palette (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs index 80fa49f47..4014d1dbd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs @@ -32,10 +32,12 @@ :opacity (:fill-opacity fill) :id color-id :file-id color-file-id - :gradient (:fill-color-gradient fill)}) + :gradient (:fill-color-gradient fill) + :image (:fill-image fill)}) (d/without-nils {:color (str/lower (:fill-color fill)) :opacity (:fill-opacity fill) - :gradient (:fill-color-gradient fill)}))] + :gradient (:fill-color-gradient fill) + :image (:fill-image fill)}))] {:attrs attrs :prop :fill :shape-id (:shape-id fill) @@ -47,16 +49,18 @@ color-id (:stroke-color-ref-id stroke) shared-libs-colors (dm/get-in shared-libs [color-file-id :data :colors]) is-shared? (contains? shared-libs-colors color-id) - has-color? (not (nil? (:stroke-color stroke))) + has-color? (or (not (nil? (:stroke-color stroke))) (not (nil? (:stroke-image stroke))) ) attrs (if (or is-shared? (= color-file-id file-id)) (d/without-nils {:color (str/lower (:stroke-color stroke)) :opacity (:stroke-opacity stroke) :id color-id :file-id color-file-id - :gradient (:stroke-color-gradient stroke)}) + :gradient (:stroke-color-gradient stroke) + :image (:stroke-image stroke)}) (d/without-nils {:color (str/lower (:stroke-color stroke)) :opacity (:stroke-opacity stroke) - :gradient (:stroke-color-gradient stroke)}))] + :gradient (:stroke-color-gradient stroke) + :image (:stroke-image stroke)}))] (when has-color? {:attrs attrs :prop :stroke diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs index e4111beb8..2979eed23 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs @@ -163,7 +163,8 @@ :opacity (:fill-opacity value) :id (:fill-color-ref-id value) :file-id (:fill-color-ref-file value) - :gradient (:fill-color-gradient value)} + :gradient (:fill-color-gradient value) + :image (:fill-image value)} :key index :index index :title (tr "workspace.options.fill") @@ -215,7 +216,8 @@ :opacity (:fill-opacity value) :id (:fill-color-ref-id value) :file-id (:fill-color-ref-file value) - :gradient (:fill-color-gradient value)} + :gradient (:fill-color-gradient value) + :image (:fill-image value)} :key index :index index :title (tr "workspace.options.fill") diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs index e108fd738..dcf8d5753 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs @@ -105,9 +105,10 @@ (mf/use-fn (mf/deps grid) (fn [color] - (-> grid - (update :params assoc :color color) - (on-change)))) + (let [color (dissoc color :id :file-id)] + (-> grid + (update :params assoc :color color) + (on-change))))) handle-detach-color (mf/use-fn @@ -189,6 +190,7 @@ [:& color-row {:color (:color params) :title (tr "workspace.options.grid.params.color") :disable-gradient true + :disable-image true :on-change handle-change-color :on-detach handle-detach-color}] [:button {:class (stl/css :show-more-options) @@ -228,6 +230,7 @@ [:& color-row {:color (:color params) :title (tr "workspace.options.grid.params.color") :disable-gradient true + :disable-image true :on-change handle-change-color :on-detach handle-detach-color}]]] @@ -384,6 +387,7 @@ [:& color-row {:color (:color params) :title (tr "workspace.options.grid.params.color") :disable-gradient true + :disable-image true :on-change handle-change-color :on-detach handle-detach-color}] [:div.row-flex diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs index 9d101d6ee..97f329c5c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs @@ -115,7 +115,8 @@ (fn [color] (st/emit! (dch/update-shapes ids - #(assoc-in % [:shadow index :color] color))))) + #(assoc-in % [:shadow index :color] + (dissoc color :id :file-id)))))) detach-color (mf/use-fn @@ -242,6 +243,7 @@ (:color value)) :title (tr "workspace.options.shadow-options.color") :disable-gradient true + :disable-image true :on-change update-color :on-detach detach-color :on-open manage-on-open @@ -335,6 +337,7 @@ (:color value)) :title (tr "workspace.options.shadow-options.color") :disable-gradient true + :disable-image true :on-change update-color :on-detach detach-color :on-open manage-on-open diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs index fa0f3d23d..dbdd28507 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs @@ -38,6 +38,7 @@ [:& color-row {:disable-gradient true :disable-opacity true + :disable-image true :title (tr "workspace.options.canvas-background") :color {:color (get options :background clr/canvas) :opacity 1} @@ -51,6 +52,7 @@ [:& color-row {:disable-gradient true :disable-opacity true + :disable-image true :title (tr "workspace.options.canvas-background") :color {:color (get options :background clr/canvas) :opacity 1} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs index adce16118..09d22f7ed 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs @@ -62,6 +62,8 @@ gradient-color? (and (not multiple-colors?) (:gradient color) (get-in color [:gradient :type])) + image-color? (and (not multiple-colors?) + (:image color)) editing-text* (mf/use-state false) editing-text? (deref editing-text*) @@ -218,6 +220,12 @@ [:div {:class (stl/css :color-name)} (uc/gradient-type->string (get-in color [:gradient :type]))]] + ;; Rendering an image + image-color? + [:* + [:div {:class (stl/css :color-name)} + (tr "media.image")]] + ;; Rendering a plain color :else [:span {:class (stl/css :color-input-wrapper)} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index d938043c3..08a30d709 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -188,12 +188,13 @@ :dnd-over-top (= (:over dprops) :top) :dnd-over-bot (= (:over dprops) :bot)) :ref dref} - ;; Stroke Color + ;; Stroke Color [:& color-row {:color {:color (:stroke-color stroke) :opacity (:stroke-opacity stroke) :id (:stroke-color-ref-id stroke) :file-id (:stroke-color-ref-file stroke) - :gradient (:stroke-color-gradient stroke)} + :gradient (:stroke-color-gradient stroke) + :image (:stroke-image stroke)} :index index :title title :on-change on-color-change-refactor @@ -263,7 +264,8 @@ :opacity (:stroke-opacity stroke) :id (:stroke-color-ref-id stroke) :file-id (:stroke-color-ref-file stroke) - :gradient (:stroke-color-gradient stroke)} + :gradient (:stroke-color-gradient stroke) + :image (:stroke-image stroke)} :index index :title title :on-change (on-color-change index) diff --git a/frontend/src/app/util/color.cljs b/frontend/src/app/util/color.cljs index f581a7742..5bc165580 100644 --- a/frontend/src/app/util/color.cljs +++ b/frontend/src/app/util/color.cljs @@ -74,7 +74,7 @@ (= file-id :multiple))) (def empty-color - (into {} (map #(vector % nil)) [:color :id :file-id :gradient :opacity])) + (into {} (map #(vector % nil)) [:color :id :file-id :gradient :opacity :image])) (defn get-color-name [color] diff --git a/frontend/src/app/util/import/parser.cljs b/frontend/src/app/util/import/parser.cljs index 0300c1763..42063204f 100644 --- a/frontend/src/app/util/import/parser.cljs +++ b/frontend/src/app/util/import/parser.cljs @@ -101,6 +101,10 @@ (get-in (get-data m) [:attrs ns-att]))] (when val (val-fn val))))) +(defn find-node-by-metadata-value + [meta value coll] + (->> coll (d/seek #(= value (get-meta % meta))))) + (defn get-children [node] (cond-> (:content node) @@ -429,7 +433,7 @@ fill-color-ref-file (get-meta node :fill-color-ref-file uuid/uuid) meta-fill-color (get-meta node :fill-color) meta-fill-opacity (get-meta node :fill-opacity) - meta-fill-color-gradient (if (str/starts-with? meta-fill-color "url") + meta-fill-color-gradient (if (str/starts-with? meta-fill-color "url#fill-color-gradient") (parse-gradient node meta-fill-color) (get-meta node :fill-color-gradient)) gradient (when (str/starts-with? fill "url") @@ -465,13 +469,14 @@ (defn add-stroke [props node svg-data] - (let [stroke-style (get-meta node :stroke-style keyword) + (let [stroke-style (get-meta node :stroke-style keyword) stroke-alignment (get-meta node :stroke-alignment keyword) - stroke (:stroke svg-data) - gradient (when (str/starts-with? stroke "url") - (parse-gradient node stroke)) + stroke (:stroke svg-data) + gradient (when (str/starts-with? stroke "url(#stroke-color-gradient") + (parse-gradient node stroke)) + stroke-cap-start (get-meta node :stroke-cap-start keyword) - stroke-cap-end (get-meta node :stroke-cap-end keyword)] + stroke-cap-end (get-meta node :stroke-cap-end keyword)] (cond-> props :always @@ -728,17 +733,22 @@ (defn parse-fills [node svg-data] (let [fills-node (get-data node :penpot:fills) + images (:images node) fills (->> (find-all-nodes fills-node :penpot:fill) (mapv (fn [fill-node] - {:fill-color (when (not (str/starts-with? (get-meta fill-node :fill-color) "url")) - (get-meta fill-node :fill-color)) - :fill-color-gradient (when (str/starts-with? (get-meta fill-node :fill-color) "url") - (parse-gradient node (get-meta fill-node :fill-color))) - :fill-color-ref-file (get-meta fill-node :fill-color-ref-file uuid/uuid) - :fill-color-ref-id (get-meta fill-node :fill-color-ref-id uuid/uuid) - :fill-opacity (get-meta fill-node :fill-opacity d/parse-double)})) + (let [fill-image-id (get-meta fill-node :fill-image-id)] + {:fill-color (when (not (str/starts-with? (get-meta fill-node :fill-color) "url")) + (get-meta fill-node :fill-color)) + :fill-color-gradient (when (str/starts-with? (get-meta fill-node :fill-color) "url(#fill-color-gradient") + (parse-gradient node (get-meta fill-node :fill-color))) + :fill-image (when fill-image-id + (get images fill-image-id)) + :fill-color-ref-file (get-meta fill-node :fill-color-ref-file uuid/uuid) + :fill-color-ref-id (get-meta fill-node :fill-color-ref-id uuid/uuid) + :fill-opacity (get-meta fill-node :fill-opacity d/parse-double)}))) (mapv d/without-nils) (filterv #(not= (:fill-color %) "none")))] + (if (seq fills) fills (->> [(-> (add-fill {} node svg-data) @@ -748,22 +758,27 @@ (defn parse-strokes [node svg-data] (let [strokes-node (get-data node :penpot:strokes) + images (:images node) strokes (->> (find-all-nodes strokes-node :penpot:stroke) (mapv (fn [stroke-node] - {:stroke-color (when (not (str/starts-with? (get-meta stroke-node :stroke-color) "url")) - (get-meta stroke-node :stroke-color)) - :stroke-color-gradient (when (str/starts-with? (get-meta stroke-node :stroke-color) "url") - (parse-gradient node (get-meta stroke-node :stroke-color))) - :stroke-color-ref-file (get-meta stroke-node :stroke-color-ref-file uuid/uuid) - :stroke-color-ref-id (get-meta stroke-node :stroke-color-ref-id uuid/uuid) - :stroke-opacity (get-meta stroke-node :stroke-opacity d/parse-double) - :stroke-style (get-meta stroke-node :stroke-style keyword) - :stroke-width (get-meta stroke-node :stroke-width d/parse-double) - :stroke-alignment (get-meta stroke-node :stroke-alignment keyword) - :stroke-cap-start (get-meta stroke-node :stroke-cap-start keyword) - :stroke-cap-end (get-meta stroke-node :stroke-cap-end keyword)})) + (let [stroke-image-id (get-meta stroke-node :stroke-image-id)] + {:stroke-color (when (not (str/starts-with? (get-meta stroke-node :stroke-color) "url")) + (get-meta stroke-node :stroke-color)) + :stroke-color-gradient (when (str/starts-with? (get-meta stroke-node :stroke-color) "url(#stroke-color-gradient") + (parse-gradient node (get-meta stroke-node :stroke-color))) + :stroke-image (when stroke-image-id + (get images stroke-image-id)) + :stroke-color-ref-file (get-meta stroke-node :stroke-color-ref-file uuid/uuid) + :stroke-color-ref-id (get-meta stroke-node :stroke-color-ref-id uuid/uuid) + :stroke-opacity (get-meta stroke-node :stroke-opacity d/parse-double) + :stroke-style (get-meta stroke-node :stroke-style keyword) + :stroke-width (get-meta stroke-node :stroke-width d/parse-double) + :stroke-alignment (get-meta stroke-node :stroke-alignment keyword) + :stroke-cap-start (get-meta stroke-node :stroke-cap-start keyword) + :stroke-cap-end (get-meta stroke-node :stroke-cap-end keyword)}))) (mapv d/without-nils) (filterv #(not= (:stroke-color %) "none")))] + (if (seq strokes) strokes (->> [(-> (add-stroke {} node svg-data) @@ -804,6 +819,25 @@ (cond-> (d/not-empty? grids) (assoc :grids grids))))) +(defn get-stroke-images-data + [node] + (let [strokes + (-> node + (find-node :penpot:shape) + (find-node :penpot:strokes))] + (->> (find-all-nodes strokes :penpot:stroke) + (mapv (fn [stroke-node] + (let [id (get-in stroke-node [:attrs :penpot:stroke-image-id]) + image-node (->> node (node-seq) (find-node-by-id id))] + {:id id + :href (get-in image-node [:attrs :href])}))) + (filterv #(some? (:id %)))))) + +(defn has-stroke-images? + [node] + (let [stroke-images (get-stroke-images-data node)] + (> (count stroke-images) 0))) + (defn has-image? [node] (let [type (get-type node) @@ -812,7 +846,9 @@ (find-node :defs) (find-node :pattern) (find-node :g) + (find-node :g) (find-node :image))] + (or (= type :image) (some? pattern-image)))) @@ -827,12 +863,32 @@ (find-node :defs) (find-node :pattern) (find-node :g) + (find-node :g) (find-node :image) :attrs) image-data (get-svg-data :image node) - svg-data (or image-data pattern-data)] + svg-data (or pattern-data image-data)] (or (:href svg-data) (:xlink:href svg-data)))) +(defn get-fill-images-data + [node] + (let [fills + (-> node + (find-node :penpot:shape) + (find-node :penpot:fills))] + (->> (find-all-nodes fills :penpot:fill) + (mapv (fn [fill-node] + (let [id (get-in fill-node [:attrs :penpot:fill-image-id]) + image-node (->> node (node-seq) (find-node-by-id id))] + {:id id + :href (get-in image-node [:attrs :href])}))) + (filterv #(some? (:id %)))))) + +(defn has-fill-images? + [node] + (let [fill-images (get-fill-images-data node)] + (> (count fill-images) 0))) + (defn get-image-fill [node] (let [linear-gradient-node (-> node diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index b1ef6253c..9895aaf47 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -317,36 +317,59 @@ (defn resolve-media [context file-id node] - (if (and (not (cip/close? node)) - (cip/has-image? node)) - (let [name (cip/get-image-name node) - image-data (cip/get-image-data node) - image-fill (cip/get-image-fill node)] - (->> (upload-media-files context file-id name image-data) - (rx/catch #(do (.error js/console "Error uploading media: " name) - (rx/of node))) + (if (or (and (not (cip/close? node)) + (cip/has-image? node)) + (cip/has-stroke-images? node) + (cip/has-fill-images? node)) + (let [name (cip/get-image-name node) + has-image (cip/has-image? node) + image-data (cip/get-image-data node) + image-fill (cip/get-image-fill node) + fill-images-data (->> (cip/get-fill-images-data node) + (map #(assoc % :type :fill))) + stroke-images-data (->> (cip/get-stroke-images-data node) + (map #(assoc % :type :stroke))) + + images-data (concat + fill-images-data + stroke-images-data + (when has-image + [{:href image-data}]))] + (->> (rx/from images-data) + (rx/mapcat (fn [image-data] + (->> (upload-media-files context file-id name (:href image-data)) + (rx/catch #(do (.error js/console "Error uploading media: " name) + (rx/of node))) + (rx/map #(vector (:id image-data) %))))) + (rx/reduce (fn [acc [id data]] (assoc acc id data)) {}) (rx/map - (fn [media] - (-> node - (assoc-in [:attrs :penpot:media-id] (:id media)) - (assoc-in [:attrs :penpot:media-width] (:width media)) - (assoc-in [:attrs :penpot:media-height] (:height media)) - (assoc-in [:attrs :penpot:media-mtype] (:mtype media)) + (fn [images] + (let [media (get images nil)] + (-> node + (assoc :images images) + (cond-> (some? media) + (-> + (assoc-in [:attrs :penpot:media-id] (:id media)) + (assoc-in [:attrs :penpot:media-width] (:width media)) + (assoc-in [:attrs :penpot:media-height] (:height media)) + (assoc-in [:attrs :penpot:media-mtype] (:mtype media)) - (assoc-in [:attrs :penpot:fill-color] (:fill image-fill)) - (assoc-in [:attrs :penpot:fill-color-ref-file] (:fill-color-ref-file image-fill)) - (assoc-in [:attrs :penpot:fill-color-ref-id] (:fill-color-ref-id image-fill)) - (assoc-in [:attrs :penpot:fill-opacity] (:fill-opacity image-fill)) - (assoc-in [:attrs :penpot:fill-color-gradient] (:fill-color-gradient image-fill))))))) + (assoc-in [:attrs :penpot:fill-color] (:fill image-fill)) + (assoc-in [:attrs :penpot:fill-color-ref-file] (:fill-color-ref-file image-fill)) + (assoc-in [:attrs :penpot:fill-color-ref-id] (:fill-color-ref-id image-fill)) + (assoc-in [:attrs :penpot:fill-opacity] (:fill-opacity image-fill)) + (assoc-in [:attrs :penpot:fill-color-gradient] (:fill-color-gradient image-fill)))))))))) ;; If the node is not an image just return the node (->> (rx/of node) (rx/observe-on :async)))) (defn media-node? [node] - (and (cip/shape? node) - (cip/has-image? node) - (not (cip/close? node)))) + (or (and (cip/shape? node) + (cip/has-image? node) + (not (cip/close? node))) + (cip/has-stroke-images? node) + (cip/has-fill-images? node))) (defn import-page [context file [page-id page-name content]] @@ -379,7 +402,8 @@ (rx/mapcat (fn [node] (->> (resolve-media context file-id node) - (rx/map (fn [result] [node result]))))) + (rx/map (fn [result] + [node result]))))) (rx/reduce conj {}))] (->> pre-process-images diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 09683b188..7bb654829 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -4993,3 +4993,21 @@ msgstr "Click to close the path" #, markdown msgid "workspace.top-bar.read-only" msgstr "**Inspect mode** (View Only)" + +msgid "media.image" +msgstr "Image" + +msgid "media.solid" +msgstr "Solid" + +msgid "media.linear" +msgstr "Linear" + +msgid "media.radial" +msgstr "Radial" + +msgid "media.gradient" +msgstr "Gradient" + +msgid "media.choose-image" +msgstr "Choose image" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 9e145e578..68cc7036d 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -5094,3 +5094,21 @@ msgstr "Pulsar para cerrar la ruta" #, markdown msgid "workspace.top-bar.read-only" msgstr "**Modo inspección** (View only)" + +msgid "media.image" +msgstr "Imagen" + +msgid "media.solid" +msgstr "Sólido" + +msgid "media.linear" +msgstr "Linear" + +msgid "media.radial" +msgstr "Radial" + +msgid "media.gradient" +msgstr "Gradiente" + +msgid "media.choose-image" +msgstr "Elegir imagen"