From 838c1324b9aefa89f304323346d4b59c63183108 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 4 Nov 2024 09:16:48 +0100 Subject: [PATCH] :tada: Add new gradients UI --- CHANGES.md | 2 + common/src/app/common/colors.cljc | 60 +++ common/src/app/common/geom/shapes/points.cljc | 2 +- .../playwright/ui/specs/colorpicker.spec.js | 140 ++++++ frontend/resources/images/icons/reorder.svg | 3 + frontend/src/app/main/data/changes.cljs | 2 +- .../src/app/main/data/workspace/colors.cljs | 205 +++++++- .../app/main/ui/components/numeric_input.cljs | 3 + .../app/main/ui/ds/buttons/icon_button.cljs | 5 +- .../main/ui/ds/foundations/assets/icon.cljs | 27 +- frontend/src/app/main/ui/modal.cljs | 3 +- .../src/app/main/ui/shapes/gradients.cljs | 8 +- .../app/main/ui/workspace/colorpicker.cljs | 205 ++++++-- .../app/main/ui/workspace/colorpicker.scss | 11 + .../ui/workspace/colorpicker/gradients.cljs | 377 ++++++++++++++- .../ui/workspace/colorpicker/gradients.scss | 220 +++++++-- .../ui/workspace/colorpicker/shortcuts.cljs | 28 ++ .../sidebar/options/rows/color_row.cljs | 14 +- .../sidebar/options/rows/color_row.scss | 8 - .../main/ui/workspace/viewport/gradients.cljs | 447 +++++++++++++----- frontend/translations/en.po | 1 - 21 files changed, 1490 insertions(+), 281 deletions(-) create mode 100644 frontend/resources/images/icons/reorder.svg create mode 100644 frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs diff --git a/CHANGES.md b/CHANGES.md index 7dc3fb189..8a7df1fc1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,8 @@ ### :sparkles: New features +- New gradients UI with multi-stop support. + ### :bug: Bugs fixed diff --git a/common/src/app/common/colors.cljc b/common/src/app/common/colors.cljc index 1f34903a4..932d79d63 100644 --- a/common/src/app/common/colors.cljc +++ b/common/src/app/common/colors.cljc @@ -478,3 +478,63 @@ a (+ (* ah 100) (* av 10)) b (+ (* bh 100) (* bv 10))] (compare a b))) + +(defn interpolate-color + [c1 c2 offset] + (cond + (<= offset (:offset c1)) (assoc c1 :offset offset) + (>= offset (:offset c2)) (assoc c2 :offset offset) + + :else + (let [tr-offset (/ (- offset (:offset c1)) (- (:offset c2) (:offset c1))) + [r1 g1 b1] (hex->rgb (:color c1)) + [r2 g2 b2] (hex->rgb (:color c2)) + a1 (:opacity c1) + a2 (:opacity c2) + r (+ r1 (* (- r2 r1) tr-offset)) + g (+ g1 (* (- g2 g1) tr-offset)) + b (+ b1 (* (- b2 b1) tr-offset)) + a (+ a1 (* (- a2 a1) tr-offset))] + {:color (rgb->hex [r g b]) + :opacity a + :r r + :g g + :b b + :alpha a + :offset offset}))) + +(defn- offset-spread + [from to num] + (->> (range 0 num) + (map #(mth/precision (+ from (* (/ (- to from) (dec num)) %)) 2)))) + +(defn uniform-spread? + "Checks if the gradient stops are spread uniformly" + [stops] + (let [cs (count stops) + from (first stops) + to (last stops) + expect-vals (offset-spread (:offset from) (:offset to) cs) + + calculate-expected + (fn [expected-offset stop] + (and (mth/close? (:offset stop) expected-offset) + (let [ec (interpolate-color from to expected-offset)] + (and (= (:color ec) (:color stop)) + (= (:opacity ec) (:opacity stop))))))] + (->> (map calculate-expected expect-vals stops) + (every? true?)))) + +(defn uniform-spread + "Assign an uniform spread to the offset values for the gradient" + [from to num-stops] + (->> (offset-spread (:offset from) (:offset to) num-stops) + (mapv (fn [offset] + (interpolate-color from to offset))))) + +(defn interpolate-gradient + [stops offset] + (let [idx (d/index-of-pred stops #(<= offset (:offset %))) + start (if (= idx 0) (first stops) (get stops (dec idx))) + end (if (nil? idx) (last stops) (get stops idx))] + (interpolate-color start end offset))) diff --git a/common/src/app/common/geom/shapes/points.cljc b/common/src/app/common/geom/shapes/points.cljc index 83c110bb7..0a097de1a 100644 --- a/common/src/app/common/geom/shapes/points.cljc +++ b/common/src/app/common/geom/shapes/points.cljc @@ -74,7 +74,7 @@ (-> p2 (gpt/add right-v) (gpt/add bottom-v)) (-> p3 (gpt/add left-v) (gpt/add bottom-v))]))) -(defn- project-t +(defn project-t "Given a point and a line returns the parametric t the cross point with the line going through the other axis projected" [point [start end] other-axis-vec] diff --git a/frontend/playwright/ui/specs/colorpicker.spec.js b/frontend/playwright/ui/specs/colorpicker.spec.js index 0ea20f52b..424fe70e6 100644 --- a/frontend/playwright/ui/specs/colorpicker.spec.js +++ b/frontend/playwright/ui/specs/colorpicker.spec.js @@ -22,3 +22,143 @@ test("Bug 7549 - User clicks on color swatch to display the color picker next to const distance = swatchBox.x - (pickerBox.x + pickerBox.width); expect(distance).toBeLessThan(60); }); + +test("Create a LINEAR gradient", async ({ page }) => { + const workspacePage = new WorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.mockRPC( + /get\-file\?/, + "workspace/get-file-not-empty.json", + ); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-create-rect.json", + ); + + await workspacePage.goToWorkspace({ + fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374", + pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375", + }); + await workspacePage.clickLeafLayer("Rectangle"); + + const swatch = workspacePage.page.getByRole("button", { name: "#B1B2B5" }); + const swatchBox = await swatch.boundingBox(); + await swatch.click(); + + const select = await workspacePage.page.getByText("Solid"); + await select.click(); + + const gradOption = await workspacePage.page.getByText("Gradient"); + await gradOption.click(); + + const addStopBtn = await workspacePage.page.getByRole("button", { + name: "Add stop", + }); + await addStopBtn.click(); + await addStopBtn.click(); + await addStopBtn.click(); + + const removeBtn = await workspacePage.page + .getByTestId("colorpicker") + .getByRole("button", { name: "Remove color" }) + .nth(2); + await removeBtn.click(); + await removeBtn.click(); + + const inputColor1 = await workspacePage.page.getByPlaceholder("Mixed").nth(1); + await inputColor1.fill("fabada"); + + const inputOpacity1 = await workspacePage.page + .getByTestId("colorpicker") + .getByPlaceholder("--") + .nth(1); + await inputOpacity1.fill("100"); + + const inputColor2 = await workspacePage.page.getByPlaceholder("Mixed").nth(2); + await inputColor2.fill("red"); + + const inputOpacity2 = await workspacePage.page + .getByTestId("colorpicker") + .getByPlaceholder("--") + .nth(2); + await inputOpacity2.fill("100"); + + const inputOpacityGlobal = await workspacePage.page + .locator("div") + .filter({ hasText: /^FillLinear gradient%$/ }) + .getByPlaceholder("--"); + await inputOpacityGlobal.fill("100"); +}); + +test("Create a RADIAL gradient", async ({ page }) => { + const workspacePage = new WorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.mockRPC( + /get\-file\?/, + "workspace/get-file-not-empty.json", + ); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-create-rect.json", + ); + + await workspacePage.goToWorkspace({ + fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374", + pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375", + }); + await workspacePage.clickLeafLayer("Rectangle"); + + const swatch = workspacePage.page.getByRole("button", { name: "#B1B2B5" }); + const swatchBox = await swatch.boundingBox(); + await swatch.click(); + + const select = await workspacePage.page.getByText("Solid"); + await select.click(); + + const gradOption = await workspacePage.page.getByText("Gradient"); + await gradOption.click(); + + const gradTypeOptions = await workspacePage.page + .getByTestId("colorpicker") + .locator("div") + .filter({ hasText: "Linear" }) + .nth(3); + await gradTypeOptions.click(); + + const gradRadialOption = await workspacePage.page + .locator("li") + .filter({ hasText: "Radial" }); + await gradRadialOption.click(); + + const addStopBtn = await workspacePage.page.getByRole("button", { + name: "Add stop", + }); + await addStopBtn.click(); + await addStopBtn.click(); + await addStopBtn.click(); + + const removeBtn = await workspacePage.page + .getByTestId("colorpicker") + .getByRole("button", { name: "Remove color" }) + .nth(2); + await removeBtn.click(); + await removeBtn.click(); + + const inputColor1 = await workspacePage.page.getByPlaceholder("Mixed").nth(1); + await inputColor1.fill("fabada"); + + const inputOpacity1 = await workspacePage.page + .getByTestId("colorpicker") + .getByPlaceholder("--") + .nth(1); + await inputOpacity1.fill("100"); + + const inputColor2 = await workspacePage.page.getByPlaceholder("Mixed").nth(2); + await inputColor2.fill("red"); + + const inputOpacity2 = await workspacePage.page + .getByTestId("colorpicker") + .getByPlaceholder("--") + .nth(2); + await inputOpacity2.fill("100"); +}); diff --git a/frontend/resources/images/icons/reorder.svg b/frontend/resources/images/icons/reorder.svg new file mode 100644 index 000000000..3b3160d80 --- /dev/null +++ b/frontend/resources/images/icons/reorder.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index 00f990763..4da9215a2 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -19,7 +19,7 @@ [potok.v2.core :as ptk])) ;; Change this to :info :debug or :trace to debug this module -(log/set-level! :debug) +(log/set-level! :info) (def page-change? #{:add-page :mod-page :del-page :mov-page}) diff --git a/frontend/src/app/main/data/workspace/colors.cljs b/frontend/src/app/main/data/workspace/colors.cljs index da8053ab9..b11632496 100644 --- a/frontend/src/app/main/data/workspace/colors.cljs +++ b/frontend/src/app/main/data/workspace/colors.cljs @@ -541,16 +541,34 @@ (ctc/check-color! color)) (ptk/reify ::apply-color-from-colorpicker - ptk/WatchEvent - (watch [_ _ _] - ;; FIXME: revisit this + ptk/UpdateEvent + (update [_ state] (let [gradient-type (dm/get-in color [:gradient :type])] - (rx/of - (cond - (:image color) (activate-colorpicker-image) - (:color color) (activate-colorpicker-color) - (= :linear gradient-type) (activate-colorpicker-gradient :linear-gradient) - (= :radial gradient-type) (activate-colorpicker-gradient :radial-gradient))))))) + (update state :colorpicker + (fn [state] + (cond + (:image color) + (-> state + (assoc :type :image) + (dissoc :editing-stop :stops :gradient)) + + (:color color) + (-> state + (assoc :type :color) + (dissoc :editing-stop :stops :gradient)) + + + (= :linear gradient-type) + (-> state + (assoc :type :linear-gradient) + (assoc :editing-stop 0) + (d/dissoc-in [:current-color :image])) + + (= :radial gradient-type) + (-> state + (assoc :type :radial-gradient) + (assoc :editing-stop 0) + (d/dissoc-in [:current-color :image]))))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -591,7 +609,7 @@ :width 1.0}) (defn get-color-from-colorpicker-state - [{:keys [type current-color stops gradient] :as state}] + [{:keys [type current-color stops gradient opacity] :as state}] (cond (= type :color) (clear-color-components current-color) @@ -600,7 +618,8 @@ (clear-image-components current-color) :else - {:gradient (-> gradient + {:opacity opacity + :gradient (-> gradient (assoc :type (case type :linear-gradient :linear :radial-gradient :radial)) @@ -625,13 +644,19 @@ (let [stopper (rx/merge (rx/filter (ptk/type? ::finalize-colorpicker) stream) (rx/filter (ptk/type? ::initialize-colorpicker) stream))] - (->> (rx/merge (->> stream (rx/filter (ptk/type? ::update-colorpicker-gradient)) - (rx/debounce 200)) + (rx/debounce 20)) (rx/filter (ptk/type? ::update-colorpicker-color) stream) - (rx/filter (ptk/type? ::activate-colorpicker-gradient) stream)) + (->> (rx/filter (ptk/type? ::activate-colorpicker-gradient) stream) + (rx/debounce 20)) + (rx/filter (ptk/type? ::update-colorpicker-stops) stream) + (rx/filter (ptk/type? ::update-colorpicker-gradient-opacity) stream) + (rx/filter (ptk/type? ::update-colorpicker-add-stop) stream) + (rx/filter (ptk/type? ::update-colorpicker-add-auto) stream) + (rx/filter (ptk/type? ::remove-gradient-stop) stream)) + (rx/debounce 40) (rx/map (constantly (colorpicker-onchange-runner on-change))) (rx/take-until stopper)))) @@ -660,14 +685,18 @@ (let [current-color (:current-color state)] (if (some? gradient) (let [stop (or (:editing-stop state) 0) - stops (mapv split-color-components (:stops gradient))] - (-> state - (assoc :current-color (nth stops stop)) - (assoc :stops stops) - (assoc :gradient (-> gradient - (dissoc :stops) - (assoc :shape-id shape-id))) - (assoc :editing-stop stop))) + new-stops (mapv split-color-components (:stops gradient)) + new-gradient (-> gradient + (dissoc :stops) + (assoc :shape-id shape-id))] + (if (and (= (:stops state) new-stops) (= (:gradient state) new-gradient)) + state + (-> state + (assoc :opacity (:opacity data)) + (assoc :current-color (get new-stops stop)) + (assoc :stops new-stops) + (assoc :gradient new-gradient) + (assoc :editing-stop stop)))) (-> state (cond-> (or (nil? current-color) @@ -678,6 +707,132 @@ (dissoc :gradient) (dissoc :stops)))))))))) +(defn update-colorpicker-gradient-opacity + [opacity] + (ptk/reify ::update-colorpicker-gradient-opacity + ptk/UpdateEvent + (update [_ state] + (update state :colorpicker + (fn [state] + (-> state + (assoc :opacity opacity))))))) + +(defn update-colorpicker-add-auto + [] + (ptk/reify ::update-colorpicker-add-auto + ptk/UpdateEvent + (update [_ state] + (update state :colorpicker + (fn [{:keys [stops editing-stop] :as state}] + (if (cc/uniform-spread? stops) + ;; Add to uniform + (let [stops (->> (cc/uniform-spread (first stops) (last stops) (inc (count stops))) + (mapv split-color-components))] + (-> state + (assoc :current-color (get stops editing-stop)) + (assoc :stops stops))) + + ;; We add the stop to the middle point between the selected + ;; and the next one. + ;; If the last stop is selected then it's added between the + ;; last two stops. + (let [index + (if (= editing-stop (dec (count stops))) + (dec editing-stop) + editing-stop) + + {from-offset :offset} (get stops index) + {to-offset :offset} (get stops (inc index)) + + half-point-offset + (+ from-offset (/ (- to-offset from-offset) 2)) + + new-stop (-> (cc/interpolate-gradient stops half-point-offset) + (split-color-components)) + + stops (conj stops new-stop) + stops (into [] (sort-by :offset stops)) + editing-stop (d/index-of-pred stops #(= new-stop %))] + (-> state + (assoc :editing-stop editing-stop) + (assoc :current-color (get stops editing-stop)) + (assoc :stops stops))))))))) + +(defn update-colorpicker-add-stop + [offset] + (ptk/reify ::update-colorpicker-add-stop + ptk/UpdateEvent + (update [_ state] + (update state :colorpicker + (fn [state] + (let [stops (:stops state) + new-stop (-> (cc/interpolate-gradient stops offset) + (split-color-components)) + stops (conj stops new-stop) + stops (into [] (sort-by :offset stops)) + editing-stop (d/index-of-pred stops #(= new-stop %))] + (-> state + (assoc :editing-stop editing-stop) + (assoc :current-color (get stops editing-stop)) + (assoc :stops stops)))))))) + +(defn update-colorpicker-stops + [stops] + (ptk/reify ::update-colorpicker-stops + ptk/UpdateEvent + (update [_ state] + (update state :colorpicker + (fn [state] + (let [stop (or (:editing-stop state) 0) + stops (mapv split-color-components stops)] + (-> state + (assoc :current-color (get stops stop)) + (assoc :stops stops)))))))) + +(defn sort-colorpicker-stops + [] + (ptk/reify ::sort-colorpicker-stops + ptk/UpdateEvent + (update [_ state] + (update state :colorpicker + (fn [state] + (let [stop (or (:editing-stop state) 0) + stops (mapv split-color-components (:stops state)) + stop-val (get stops stop) + stops (into [] (sort-by :offset stops)) + stop (d/index-of-pred stops #(= % stop-val))] + (-> state + (assoc :editing-stop stop) + (assoc :stops stops)))))))) + +(defn remove-gradient-stop + ([] + (remove-gradient-stop nil)) + + ([index] + (ptk/reify ::remove-gradient-stop + ptk/UpdateEvent + (update [_ state] + (update state :colorpicker + (fn [{:keys [editing-stop stops] :as state}] + (if (> (count stops) 2) + (let [delete-index (or index editing-stop 0) + delete-stop (get stops delete-index) + stops (into [] (remove #(= delete-stop %)) stops) + + editing-stop + (cond + (< editing-stop delete-index) editing-stop + (> editing-stop delete-index) (dec editing-stop) + (>= (count stops) editing-stop) (dec (count stops)) + :else editing-stop)] + (-> state + (assoc :editing-stop editing-stop) + (assoc :stops stops))) + + ;; Cannot delete + state))))))) + (defn update-colorpicker-color [changes add-recent?] (ptk/reify ::update-colorpicker-color @@ -723,16 +878,16 @@ (update-in state [:colorpicker :gradient] merge changes)))) (defn select-colorpicker-gradient-stop - [stop] + [index] (ptk/reify ::select-colorpicket-gradient-stop ptk/UpdateEvent (update [_ state] (update state :colorpicker (fn [state] - (if-let [color (get-in state [:stops stop])] + (if-let [color (get-in state [:stops index])] (assoc state :current-color color - :editing-stop stop) + :editing-stop index) state)))))) (defn activate-colorpicker-color diff --git a/frontend/src/app/main/ui/components/numeric_input.cljs b/frontend/src/app/main/ui/components/numeric_input.cljs index 4a1823868..510738b0b 100644 --- a/frontend/src/app/main/ui/components/numeric_input.cljs +++ b/frontend/src/app/main/ui/components/numeric_input.cljs @@ -211,9 +211,12 @@ handle-focus (mf/use-callback + (mf/deps on-focus select-on-focus?) (fn [event] + (reset! last-value* (parse-value)) (let [target (dom/get-target event)] (when on-focus + (mf/set-ref-val! dirty-ref true) (on-focus event)) (when select-on-focus? diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs index 68895cc74..0b45ad238 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs @@ -15,6 +15,7 @@ (def ^:private schema:icon-button [:map [:class {:optional true} :string] + [:icon-class {:optional true} :string] [:icon [:and :string [:fn #(contains? icon-list %)]]] [:aria-label :string] @@ -24,7 +25,7 @@ (mf/defc icon-button* {::mf/props :obj ::mf/schema schema:icon-button} - [{:keys [class icon variant aria-label children] :rest props}] + [{:keys [class icon icon-class variant aria-label children] :rest props}] (let [variant (or variant "primary") class (dm/str class " " (stl/css-case :icon-button true :icon-button-primary (= variant "primary") @@ -33,4 +34,4 @@ :icon-button-action (= variant "action") :icon-button-destructive (= variant "destructive"))) props (mf/spread-props props {:class class :title aria-label})] - [:> "button" props [:> icon* {:id icon :aria-label aria-label}] children])) + [:> "button" props [:> icon* {:id icon :aria-label aria-label :class icon-class}] children])) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index 585282db5..5609a3749 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -50,10 +50,10 @@ (def ^:icon-id align-top "align-top") (def ^:icon-id align-vertical-center "align-vertical-center") (def ^:icon-id arrow "arrow") -(def ^:icon-id arrow-up "arrow-up") (def ^:icon-id arrow-down "arrow-down") (def ^:icon-id arrow-left "arrow-left") (def ^:icon-id arrow-right "arrow-right") +(def ^:icon-id arrow-up "arrow-up") (def ^:icon-id asc-sort "asc-sort") (def ^:icon-id board "board") (def ^:icon-id boards-thumbnail "boards-thumbnail") @@ -93,27 +93,27 @@ (def ^:icon-id clip-content "clip-content") (def ^:icon-id clipboard "clipboard") (def ^:icon-id clock "clock") -(def ^:icon-id close-small "close-small") (def ^:icon-id close "close") +(def ^:icon-id close-small "close-small") (def ^:icon-id code "code") -(def ^:icon-id column-reverse "column-reverse") (def ^:icon-id column "column") +(def ^:icon-id column-reverse "column-reverse") (def ^:icon-id comments "comments") -(def ^:icon-id component-copy "component-copy") (def ^:icon-id component "component") +(def ^:icon-id component-copy "component-copy") (def ^:icon-id constraint-horizontal "constraint-horizontal") (def ^:icon-id constraint-vertical "constraint-vertical") +(def ^:icon-id corner-bottom "corner-bottom") (def ^:icon-id corner-bottom-left "corner-bottom-left") (def ^:icon-id corner-bottom-right "corner-bottom-right") -(def ^:icon-id corner-bottom "corner-bottom") (def ^:icon-id corner-center "corner-center") (def ^:icon-id corner-radius "corner-radius") (def ^:icon-id corner-top "corner-top") (def ^:icon-id corner-top-left "corner-top-left") (def ^:icon-id corner-top-right "corner-top-right") (def ^:icon-id curve "curve") -(def ^:icon-id delete-text "delete-text") (def ^:icon-id delete "delete") +(def ^:icon-id delete-text "delete-text") (def ^:icon-id desc-sort "desc-sort") (def ^:icon-id detach "detach") (def ^:icon-id detached "detached") @@ -122,33 +122,34 @@ (def ^:icon-id document "document") (def ^:icon-id download "download") (def ^:icon-id drop "drop") -(def ^:icon-id easing-ease-in-out "easing-ease-in-out") -(def ^:icon-id easing-ease-in "easing-ease-in") -(def ^:icon-id easing-ease-out "easing-ease-out") (def ^:icon-id easing-ease "easing-ease") +(def ^:icon-id easing-ease-in "easing-ease-in") +(def ^:icon-id easing-ease-in-out "easing-ease-in-out") +(def ^:icon-id easing-ease-out "easing-ease-out") (def ^:icon-id easing-linear "easing-linear") (def ^:icon-id effects "effects") (def ^:icon-id elipse "elipse") (def ^:icon-id exit "exit") (def ^:icon-id expand "expand") +(def ^:icon-id external-link "external-link") (def ^:icon-id feedback "feedback") (def ^:icon-id fill-content "fill-content") (def ^:icon-id filter "filter") (def ^:icon-id fixed-width "fixed-width") +(def ^:icon-id flex "flex") (def ^:icon-id flex-grid "flex-grid") (def ^:icon-id flex-horizontal "flex-horizontal") (def ^:icon-id flex-vertical "flex-vertical") -(def ^:icon-id flex "flex") (def ^:icon-id flip-horizontal "flip-horizontal") (def ^:icon-id flip-vertical "flip-vertical") (def ^:icon-id gap-horizontal "gap-horizontal") (def ^:icon-id gap-vertical "gap-vertical") (def ^:icon-id graphics "graphics") +(def ^:icon-id grid "grid") (def ^:icon-id grid-column "grid-column") (def ^:icon-id grid-columns "grid-columns") (def ^:icon-id grid-gutter "grid-gutter") (def ^:icon-id grid-margin "grid-margin") -(def ^:icon-id grid "grid") (def ^:icon-id grid-row "grid-row") (def ^:icon-id grid-rows "grid-rows") (def ^:icon-id grid-square "grid-square") @@ -165,7 +166,6 @@ (def ^:icon-id info "info") (def ^:icon-id interaction "interaction") (def ^:icon-id join-nodes "join-nodes") -(def ^:icon-id external-link "external-link") (def ^:icon-id justify-content-column-around "justify-content-column-around") (def ^:icon-id justify-content-column-between "justify-content-column-between") (def ^:icon-id justify-content-column-center "justify-content-column-center") @@ -216,6 +216,7 @@ (def ^:icon-id rectangle "rectangle") (def ^:icon-id reload "reload") (def ^:icon-id remove "remove") +(def ^:icon-id reorder "reorder") (def ^:icon-id rgba "rgba") (def ^:icon-id rgba-complementary "rgba-complementary") (def ^:icon-id rotation "rotation") @@ -285,7 +286,7 @@ (def ^:private schema:icon [:map - [:class {:optional true} :string] + [:class {:optional true} [:maybe :string]] [:id [:and :string [:fn #(contains? icon-list %)]]] [:size {:optional true} [:maybe [:enum "s" "m"]]]]) diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 0c59faa26..4cae67593 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -37,7 +37,8 @@ (when (and wrapper (not allow-click-outside) (not (.contains wrapper current)) - (not (= type (keyword (dom/get-data current "allow-click-modal"))))) + (not (= type (keyword (dom/get-data current "allow-click-modal")))) + (= (.-button event) 0)) (dom/stop-propagation event) (dom/prevent-default event) (st/emit! (dm/hide))))) diff --git a/frontend/src/app/main/ui/shapes/gradients.cljs b/frontend/src/app/main/ui/shapes/gradients.cljs index d2a74a119..a906bfbca 100644 --- a/frontend/src/app/main/ui/shapes/gradients.cljs +++ b/frontend/src/app/main/ui/shapes/gradients.cljs @@ -47,8 +47,8 @@ (add-metadata! props gradient)) [:> :linearGradient props - (for [{:keys [offset color opacity]} (:stops gradient)] - [:stop {:key (dm/str id "-stop-" offset) + (for [[index {:keys [offset color opacity]}] (d/enumerate (sort-by :offset (:stops gradient)))] + [:stop {:key (dm/str id "-stop-" index) :offset (d/nilv offset 0) :stop-color color :stop-opacity opacity}])])) @@ -109,8 +109,8 @@ (add-metadata! props gradient)) [:> :radialGradient props - (for [{:keys [offset color opacity]} (:stops gradient)] - [:stop {:key (dm/str id "-stop-" offset) + (for [[index {:keys [offset color opacity]}] (d/enumerate (:stops gradient))] + [:stop {:key (dm/str id "-stop-" index) :offset (d/nilv offset 0) :stop-color color :stop-opacity opacity}])])) diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index fe6ea243e..46d006b60 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -10,9 +10,12 @@ [app.common.colors :as cc] [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] [app.config :as cfg] [app.main.data.events :as-alias ev] [app.main.data.modal :as modal] + [app.main.data.shortcuts :as dsc] [app.main.data.workspace.colors :as dc] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.media :as dwm] @@ -20,6 +23,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.file-uploader :refer [file-uploader]] + [app.main.ui.components.numeric-input :refer [numeric-input*]] [app.main.ui.components.select :refer [select]] [app.main.ui.ds.foundations.assets.icon :as ic] [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] @@ -30,8 +34,10 @@ [app.main.ui.workspace.colorpicker.hsva :refer [hsva-selector]] [app.main.ui.workspace.colorpicker.libraries :refer [libraries]] [app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector]] + [app.main.ui.workspace.colorpicker.shortcuts :as sc] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] + [app.util.timers :as ts] [cuerdas.core :as str] [okulary.core :as l] [potok.v2.core :as ptk] @@ -51,6 +57,14 @@ (def viewport (l/derived :vport refs/workspace-local)) +(defn opacity->string + [opacity] + (if (not= opacity :multiple) + (dm/str (-> opacity + (d/coalesce 1) + (* 100))) + :multiple)) + ;; --- Color Picker Modal (defn use-color-picker-css-variables! [node-ref current-color] @@ -78,6 +92,8 @@ (let [state (mf/deref refs/colorpicker) node-ref (mf/use-ref) + should-update? (mf/use-var true) + ;; TODO: I think we need to put all this picking state under ;; the same object for avoid creating adhoc refs for each ;; value @@ -99,7 +115,12 @@ fill-image-ref (mf/use-ref nil) - selected-mode (get state :type :color) + color-type (get state :type :color) + selected-mode (case color-type + (:linear-gradient :radial-gradient) + :gradient + + color-type) disabled-color-accept? (and (= selected-mode :image) @@ -147,9 +168,9 @@ (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))))) + :gradient (st/emit! (dc/activate-colorpicker-gradient :linear-gradient)) + :image (st/emit! (dc/activate-colorpicker-image)) + nil))) handle-change-color (mf/use-fn @@ -173,14 +194,6 @@ (do (modal/allow-click-outside!) (st/emit! (dc/start-picker)))))) - handle-change-stop - (mf/use-fn - (fn [event] - (let [offset (-> (dom/get-current-target event) - (dom/get-data "value") - (d/parse-integer))] - (st/emit! (dc/select-colorpicker-gradient-stop offset))))) - on-select-library-color (mf/use-fn (mf/deps data handle-change-color) @@ -203,6 +216,7 @@ (mf/use-fn (mf/deps drag? node-ref) (fn [] + (reset! should-update? false) (reset! drag? true) (st/emit! (dwu/start-undo-transaction (mf/ref-val node-ref))))) @@ -210,6 +224,7 @@ (mf/use-fn (mf/deps drag? node-ref) (fn [] + (reset! should-update? true) (reset! drag? false) (st/emit! (dwu/commit-undo-transaction (mf/ref-val node-ref))))) @@ -225,11 +240,104 @@ (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")}]) + [{:value :gradient :label (tr "media.gradient")}]) (when (not disable-image) [{:value :image :label (tr "media.image")}]))) + handle-change-gradient-selected-stop + (mf/use-fn + (fn [index] + (st/emit! (dc/select-colorpicker-gradient-stop index)))) + + handle-change-gradient-type + (mf/use-fn + (fn [type] + (st/emit! (dc/activate-colorpicker-gradient type)))) + + handle-gradient-change-stop + (mf/use-fn + (mf/deps state) + (fn [prev-stop new-stop] + (let [stops (->> (:stops state) + (mapv #(if (= % prev-stop) new-stop %)))] + (st/emit! (dc/update-colorpicker-stops stops))))) + + handle-gradient-add-stop-auto + (mf/use-fn + (fn [] + (st/emit! (dc/update-colorpicker-add-auto)))) + + handle-gradient-add-stop-preview + (mf/use-fn + (fn [offset] + (st/emit! (dc/update-colorpicker-add-stop offset)))) + + handle-gradient-remove-stop + (mf/use-fn + (mf/deps state) + (fn [stop] + (when (> (count (:stops state)) 2) + (when-let [index (d/index-of-pred (:stops state) #(= % stop))] + (st/emit! (dc/remove-gradient-stop index)))))) + + handle-stop-edit-start + (mf/use-fn + (fn [] + (reset! should-update? false))) + + handle-stop-edit-finish + (mf/use-fn + (mf/deps state) + (fn [] + (reset! should-update? true) + + ;; Update asynchronously so we can update with the new stops + (ts/schedule #(st/emit! (dc/sort-colorpicker-stops))))) + + handle-rotate-stops + (mf/use-fn + (mf/deps state) + (fn [] + (let [gradient (:gradient state) + mtx (gmt/rotate-matrix 90 (gpt/point 0.5 0.5)) + + start-p (-> (gpt/point (:start-x gradient) (:start-y gradient)) + (gpt/transform mtx)) + + end-p (-> (gpt/point (:end-x gradient) (:end-y gradient)) + (gpt/transform mtx))] + (st/emit! (dc/update-colorpicker-gradient {:start-x (:x start-p) + :start-y (:y start-p) + :end-x (:x end-p) + :end-y (:y end-p)}))))) + + handle-reverse-stops + (mf/use-fn + (mf/deps (:stops state)) + (fn [] + (let [stops (->> (:stops state) + (map (fn [it] (update it :offset #(+ 1 (* -1 %))))) + (sort-by :offset) + (into []))] + (st/emit! (dc/update-colorpicker-stops stops))))) + + handle-reorder-stops + (mf/use-fn + (mf/deps (:stops state)) + (fn [from-index to-index] + (let [stops (:stops state) + new-stops + (-> stops + (d/insert-at-index to-index [(get stops from-index)])) + stops + (mapv #(assoc %2 :offset (:offset %1)) stops new-stops)] + (st/emit! (dc/update-colorpicker-stops stops))))) + + handle-change-gradient-opacity + (mf/use-fn + (fn [value] + (st/emit! (dc/update-colorpicker-gradient-opacity (/ value 100))))) + tabs #js [#js {:aria-label (tr "workspace.libraries.colors.rgba") :icon ic/rgba @@ -280,7 +388,8 @@ ;; Update colorpicker with external color changes (mf/with-effect [data] - (st/emit! (dc/update-colorpicker data))) + (when @should-update? + (st/emit! (dc/update-colorpicker data)))) ;; Updates the CSS color variable when there is a change in the color (use-color-picker-css-variables! node-ref current-color) @@ -300,24 +409,46 @@ :ref node-ref :style {:touch-action "none"}} [:div {:class (stl/css :top-actions)} - (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}]]) + [:div {:class (stl/css :top-actions-right)} + (when (= :gradient selected-mode) + [:div {:class (stl/css :opacity-input-wrapper)} + [:span {:class (stl/css :icon-text)} "%"] + [:> numeric-input* + {:value (-> data :opacity opacity->string) + :on-change handle-change-gradient-opacity + :default 100 + :min 0 + :max 100}]]) + + (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])] - (when (or (= selected-mode :linear-gradient) - (= selected-mode :radial-gradient)) + (when (= selected-mode :gradient) [:& gradients - {:stops (:stops state) + {:type (:type state) + :stops (:stops state) :editing-stop (:editing-stop state) - :on-select-stop handle-change-stop}]) + :on-stop-edit-start handle-stop-edit-start + :on-stop-edit-finish handle-stop-edit-finish + :on-select-stop handle-change-gradient-selected-stop + :on-change-type handle-change-gradient-type + :on-change-stop handle-gradient-change-stop + :on-add-stop-auto handle-gradient-add-stop-auto + :on-add-stop-preview handle-gradient-add-stop-preview + :on-remove-stop handle-gradient-remove-stop + :on-rotate-stops handle-rotate-stops + :on-reverse-stops handle-reverse-stops + :on-reorder-stops handle-reorder-stops}]) (if (= selected-mode :image) (let [uri (cfg/resolve-file-media (:image current-color)) @@ -383,9 +514,9 @@ (defn calculate-position "Calculates the style properties for the given coordinates and position" - [{vh :height} position x y] + [{vh :height} position x y gradient?] (let [;; picker size in pixels - h 510 + h (if gradient? 820 510) w 284 ;; Checks for overflow outside the viewport height max-y (- vh h) @@ -433,22 +564,25 @@ dirty? (mf/use-var false) last-change (mf/use-var nil) position (d/nilv position :left) - style (calculate-position vport position x y) + style (calculate-position vport position x y (some? (:gradient data))) on-change' (mf/use-fn (mf/deps on-change) (fn [new-data] (reset! dirty? (not= data new-data)) - (reset! last-change new-data) - - (if (fn? on-change) - (on-change new-data) - (st/emit! (dc/update-colorpicker new-data)))))] + (when (not= new-data @last-change) + (reset! last-change new-data) + (if (fn? on-change) + (on-change new-data) + (st/emit! (dc/update-colorpicker new-data))))))] (mf/with-effect [] - #(when (and @dirty? @last-change on-close) - (on-close @last-change))) + (st/emit! (st/emit! (dsc/push-shortcuts ::colorpicker sc/shortcuts))) + #(do + (st/emit! (dsc/pop-shortcuts ::colorpicker)) + (when (and @dirty? @last-change on-close) + (on-close @last-change)))) [:div {:class (stl/css :colorpicker-tooltip) :data-testid "colorpicker" @@ -460,4 +594,3 @@ :disable-image disable-image :on-change on-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 cc739678d..0c9486c1a 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker.scss @@ -28,6 +28,17 @@ height: $s-40; } +.top-actions-right { + display: flex; + gap: $s-8; +} + +.opacity-input-wrapper { + @extend .input-element; + @include bodySmallTypography; + width: $s-68; +} + .picker-btn { @include buttonStyle; @include flexCenter; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs index 28ea90b57..54134f6aa 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.cljs @@ -7,35 +7,368 @@ (ns app.main.ui.workspace.colorpicker.gradients (:require-macros [app.main.style :as stl]) (:require + [app.common.colors :as cc] + [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.math :as mth] + [app.main.ui.components.numeric-input :refer [numeric-input*]] + [app.main.ui.components.select :refer [select]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.formats :as fmt] + [app.main.ui.hooks :as h] + [app.main.ui.workspace.sidebar.options.rows.color-row :refer [color-row]] + [app.util.dom :as dom] [cuerdas.core :as str] [rumext.v2 :as mf])) -(defn- format-rgba - [{:keys [r g b alpha offset]}] - (str/ffmt "rgba(%1, %2, %3, %4) %5%%" r g b alpha (* offset 100))) +(defn offset->string + [opacity] + (str (-> opacity + (d/coalesce 1) + (* 100) + (fmt/format-number)))) + +(defn- event->offset + [^js event] + (/ (.. event -nativeEvent -offsetX) + (-> event dom/get-current-target dom/get-bounding-rect :width))) + +;; (defn- format-rgba +;; [{:keys [r g b alpha offset]}] +;; (str/ffmt "rgba(%1, %2, %3, %4) %5%%" r g b alpha (* offset 100))) + +(defn- format-rgb + [{:keys [r g b offset]}] + (str/ffmt "rgb(%1, %2, %3) %4%%" r g b (* offset 100))) (defn- gradient->string [stops] - (let [gradient-css (str/join ", " (map format-rgba stops))] - (str/ffmt "linear-gradient(90deg, %1)" gradient-css))) + (->> stops + (sort-by :offset) + (map (fn [{:keys [color opacity offset]}] + (let [[r g b] (cc/hex->rgb color)] + {:r r :g g :b b :alpha opacity :offset offset}))) + (map format-rgb) + (str/join ", ") + (str/ffmt "linear-gradient(90deg, %1)"))) + +(mf/defc stop-input-row + [{:keys [stop + index + is-selected + + on-select-stop + on-change-stop + on-remove-stop + on-reorder-stops + on-focus-stop-offset + on-blur-stop-offset + on-focus-stop-color + on-blur-stop-color]}] + (let [{:keys [color opacity offset]} stop + + handle-change-stop-color + (mf/use-callback + (mf/deps on-change-stop stop) + (fn [value] + (on-change-stop stop (merge stop value)))) + + handle-change-offset + (mf/use-callback + (mf/deps on-change-stop stop) + (fn [value] + (on-change-stop stop (assoc stop :offset (mth/precision (/ value 100) 2))))) + + handle-remove-stop + (mf/use-callback + (mf/deps on-remove-stop stop) + (fn [] + (when on-remove-stop + (on-remove-stop stop)))) + + handle-focus-stop-offset + (mf/use-fn + (mf/deps on-select-stop on-focus-stop-offset index) + (fn [] + (on-select-stop index) + (when on-focus-stop-offset + (on-focus-stop-offset)))) + + handle-blur-stop-offset + (mf/use-fn + (mf/deps on-select-stop on-blur-stop-offset index) + (fn [] + (on-select-stop index) + (when on-blur-stop-offset + (on-blur-stop-offset)))) + + handle-focus-stop-color + (mf/use-fn + (mf/deps on-select-stop on-focus-stop-offset index) + (fn [] + (on-select-stop index) + (when on-focus-stop-color + (on-focus-stop-offset)))) + + handle-blur-stop-color + (mf/use-fn + (mf/deps on-select-stop on-blur-stop-color index) + (fn [] + (on-select-stop index) + (when on-blur-stop-color + (on-blur-stop-color)))) + + on-drop + (mf/use-fn + (mf/deps index on-reorder-stops) + (fn [position data] + (let [from-index (:index data) + to-index (if (= position :bot) (inc index) index)] + (when on-reorder-stops + (on-reorder-stops from-index to-index))))) + + [dprops dref] + (h/use-sortable + :data-type "penpot/stops" + :on-drop on-drop + :data {:index index} + :draggable? true)] + + [:div {:class (stl/css-case :gradient-stops-entry true + :is-selected is-selected + :dnd-over (= (:over dprops) :center) + :dnd-over-top (= (:over dprops) :top) + :dnd-over-bot (= (:over dprops) :bot))} + + [:div {:ref dref + :class (stl/css :reorder)} + [:> icon* + {:id i/reorder + :class (stl/css :reorder-icon) + :aria-hidden true}]] + [:hr {:class (stl/css :reorder-separator-top)}] + [:hr {:class (stl/css :reorder-separator-bottom)}] + + [:div {:class (stl/css :offset-input-wrapper)} + [:span {:class (stl/css :icon-text)} "%"] + [:> numeric-input* + {:value (-> offset offset->string) + :on-change handle-change-offset + :default 100 + :min 0 + :max 100 + :on-focus handle-focus-stop-offset + :on-blur handle-blur-stop-offset}]] + + [:& color-row + {:disable-gradient true + :disable-picker true + :color {:color color + :opacity opacity} + :index index + :on-change handle-change-stop-color + :on-remove handle-remove-stop + :on-focus handle-focus-stop-color + :on-blur handle-blur-stop-color}]])) (mf/defc gradients - [{:keys [stops editing-stop on-select-stop]}] - [:div {:class (stl/css :gradient-stops)} - [:div {:class (stl/css :gradient-background-wrapper)} - [:div {:class (stl/css :gradient-background) - :style {:background (gradient->string stops)}}]] + [{:keys [type + stops + editing-stop + on-select-stop + on-change-type + on-change-stop + on-add-stop-preview + on-add-stop-auto + on-remove-stop + on-stop-edit-start + on-stop-edit-finish + on-reverse-stops + on-rotate-stops + on-reorder-stops]}] - [:div {:class (stl/css :gradient-stop-wrapper)} - (for [{:keys [offset hex r g b alpha] :as value} stops] - [:button {:class (stl/css-case :gradient-stop true - :selected (= editing-stop offset)) - :data-value (str offset) - :on-click on-select-stop - :style {:left (dm/str (* offset 100) "%")} - :key (dm/str offset)} + (let [preview-state (mf/use-state {:hover? false :offset 0.5}) + dragging-ref (mf/use-ref false) + start-ref (mf/use-ref nil) + start-offset (mf/use-ref nil) + background-ref (mf/use-ref nil) - [:div {:class (stl/css :gradient-stop-color) - :style {:background-color hex}}] - [:div {:class (stl/css :gradient-stop-alpha) - :style {:background-color (str/ffmt "rgba(%1, %2, %3, %4)" r g b alpha)}}]])]]) + handle-select-stop + (mf/use-callback + (mf/deps on-select-stop) + (fn [event] + (when on-select-stop + (let [index (-> event dom/get-current-target (dom/get-data "index") d/read-string)] + (on-select-stop index))))) + + handle-change-type + (mf/use-callback + (mf/deps on-change-type) + (fn [event] + (when on-change-type + (on-change-type event)))) + + handle-add-stop + (mf/use-callback + (mf/deps on-add-stop-auto) + (fn [] + (when on-add-stop-auto + (on-add-stop-auto)))) + + handle-preview-enter + (mf/use-fn + (fn [] + (swap! preview-state assoc :hover? true))) + + handle-preview-leave + (mf/use-fn + (fn [] + (swap! preview-state assoc :hover? false))) + + handle-preview-move + (mf/use-fn + (fn [^js e] + (let [offset (-> (event->offset e) + (mth/precision 2))] + (swap! preview-state assoc :offset offset)))) + + handle-preview-down + (mf/use-fn + (mf/deps on-add-stop-preview) + (fn [^js e] + (let [offset (-> (event->offset e) + (mth/precision 2))] + (when on-add-stop-preview + (on-add-stop-preview offset))))) + + handle-stop-marker-pointer-down + (mf/use-fn + (mf/deps on-stop-edit-start handle-select-stop stops) + (fn [event] + (let [index (-> event dom/get-current-target (dom/get-data "index") d/read-string) + stop (get stops index)] + (dom/capture-pointer event) + (handle-select-stop event) + (mf/set-ref-val! dragging-ref true) + (mf/set-ref-val! start-ref (dom/get-client-position event)) + (mf/set-ref-val! start-offset (:offset stop)) + (on-stop-edit-start)))) + + handle-stop-marker-pointer-move + (mf/use-fn + (mf/deps on-change-stop stops) + (fn [event] + (when-let [_ (mf/ref-val dragging-ref)] + (let [start-pt (mf/ref-val start-ref) + start-offset (mf/ref-val start-offset) + + index (-> event dom/get-target (dom/get-data "index") d/read-string) + current-pt (dom/get-client-position event) + delta-x (- (:x current-pt) (:x start-pt)) + background-node (mf/ref-val background-ref) + background-width (-> background-node dom/get-bounding-rect :width) + + delta-offset (/ delta-x background-width) + stop (get stops index) + + new-offset (mth/precision (mth/clamp (+ start-offset delta-offset) 0 1) 2)] + (on-change-stop stop (assoc stop :offset new-offset)))))) + + handle-stop-marker-lost-pointer-capture + (mf/use-fn + (mf/deps on-stop-edit-finish) + (fn [event] + (dom/release-pointer event) + (mf/set-ref-val! dragging-ref false) + (mf/set-ref-val! start-ref nil) + (on-stop-edit-finish))) + + handle-rotate-gradient + (mf/use-fn + (mf/deps on-rotate-stops) + (fn [] + (when on-rotate-stops + (on-rotate-stops)))) + + handle-reverse-gradient + (mf/use-fn + (mf/deps on-reverse-stops) + (fn [] + (when on-reverse-stops + (on-reverse-stops))))] + + [:div {:class (stl/css :gradient-panel)} + [:div {:class (stl/css :gradient-preview)} + [:div {:class (stl/css :gradient-background) + :ref background-ref + :style {:background (gradient->string stops)} + :on-pointer-enter handle-preview-enter + :on-pointer-leave handle-preview-leave + :on-pointer-move handle-preview-move + :on-pointer-down handle-preview-down} + [:div {:class (stl/css :gradient-preview-stop-preview) + :style {:display (if (:hover? @preview-state) "block" "none") + "--preview-position" (dm/str (* 100 (:offset @preview-state)) "%")}}]] + + [:div {:class (stl/css :gradient-preview-stop-wrapper)} + (for [[index {:keys [color offset r g b alpha]}] (d/enumerate stops)] + [:* {:key (dm/str "preview-stop-" index)} + [:div + {:class (stl/css-case :gradient-preview-stop true + :is-selected (= editing-stop index)) + :style {"--color-solid" color + "--color-alpha" (str/ffmt "rgba(%1, %2, %3, %4)" r g b alpha) + "--position" (dm/str (* offset 100) "%")} + :data-index index + + :on-pointer-down handle-stop-marker-pointer-down + :on-pointer-move handle-stop-marker-pointer-move + :on-lost-pointer-capture handle-stop-marker-lost-pointer-capture} + [:div {:class (stl/css :gradient-preview-stop-color) + :style {:pointer-events "none"}}] + [:div {:class (stl/css :gradient-preview-stop-alpha) + :style {:pointer-events "none"}}]] + [:div {:class (stl/css :gradient-preview-stop-decoration) + :style {"--position" (dm/str (* offset 100) "%")}}]])]] + + [:div {:class (stl/css :gradient-options)} + [:& select + {:default-value type + :options [{:value :linear-gradient :label "Linear"} + {:value :radial-gradient :label "Radial"}] + :on-change handle-change-type + :class (stl/css :gradient-options-select)}] + + [:div {:class (stl/css :gradient-options-buttons)} + [:> icon-button* {:variant "ghost" + :aria-label "Rotate gradient" + :on-click handle-rotate-gradient + :icon-class (stl/css :rotate-icon) + :icon "reload"}] + [:> icon-button* {:variant "ghost" + :aria-label "Reverse gradient" + :on-click handle-reverse-gradient + :icon "switch"}] + [:> icon-button* {:variant "ghost" + :aria-label "Add stop" + :on-click handle-add-stop + :icon "add"}]]] + + [:div {:class (stl/css :gradient-stops-list)} + [:& h/sortable-container {} + (for [[index stop] (d/enumerate stops)] + [:& stop-input-row + {:key index + :stop stop + :index index + :is-selected (= editing-stop index) + :on-select-stop on-select-stop + :on-change-stop on-change-stop + :on-remove-stop on-remove-stop + :on-focus-stop-offset on-stop-edit-start + :on-blur-stop-offset on-stop-edit-finish + :on-focus-stop-color on-stop-edit-start + :on-blur-stop-color on-stop-edit-finish + :on-reorder-stops on-reorder-stops}])]] + + [:hr {:class (stl/css :gradient-separator)}]])) diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss index 374edaaa7..5147a8225 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss @@ -6,57 +6,197 @@ @import "refactor/common-refactor.scss"; -.gradient-stops { - display: flex; - height: $s-20; - width: 100%; - margin: $s-12 0; - background-color: var(--colorpicker-handlers-color); - border-radius: $br-6; +.gradient-panel { + margin-top: $s-12; + display: grid; + gap: $s-4; + grid-template-rows: $s-56 $s-32 1fr; } -.gradient-background-wrapper { - height: 100%; +.gradient-preview { width: 100%; - border-radius: $br-6; - background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAExJREFUSIljvHnz5n8GLEBNTQ2bMMOtW7ewiuNSz4RVlIpg1IKBt4Dx////WFMRqakFl/qhH0SjFhAELNRKLaNl0Qi2YLQsGrWAcgAA0gAgQPhT2rAAAAAASUVORK5CYII=") - left center; + height: 100%; + display: flex; + align-items: center; + padding: $s-12; + position: relative; } .gradient-background { - height: 100%; width: 100%; - border-radius: $br-6; - border: $s-2 solid var(--colorpicker-details-color); -} - -.gradient-stop-wrapper { - position: absolute; - width: calc(100% - $s-40); - left: $s-20; -} - -.gradient-stop { - position: absolute; - display: grid; - grid-template-columns: 50% 50%; - padding: 0; - width: $s-16; - height: $s-24; + height: $s-20; border-radius: $br-4; - margin-top: calc(-1 * $s-2); - margin-left: calc(-1 * $s-8); - border: $s-2 solid var(--colorpicker-handlers-color); + position: relative; + cursor: pointer; +} + +.gradient-preview-stop-wrapper { + position: absolute; + width: calc(100% - $s-24 - $s-4); + height: 100%; + left: $s-2; + top: calc(-1 * $s-4); + pointer-events: none; +} + +.gradient-preview-stop { + background-color: var(--color-foreground-primary); background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAADFJREFUOE9jZGBgEAFifOANPknGUQMYhkkYEEgG+NMJKAwIAbwJbdQABnBCIgRoG4gAIF8IsXB/Rs4AAAAASUVORK5CYII="); background-position: left center; - background-size: 8px; - &.selected { - border: $s-2 solid var(--colorpicker-details-color-selected); + background-size: $s-8; + border-radius: $br-6; + border: $s-2 solid var(--color-foreground-primary); + box-shadow: 0px 0px $s-4 0px var(--menu-shadow-color); + height: calc($s-24 - $s-2); + left: var(--position); + overflow: hidden; + pointer-events: initial; + position: absolute; + width: calc($s-24 - $s-2); + + &.is-selected, + &:hover { + outline: $s-2 solid var(--color-accent-primary); + } +} +.gradient-preview-stop-decoration { + background: var(--color-foreground-primary); + border-radius: 100%; + bottom: $s-32; + box-shadow: 0px 0px $s-4 0px var(--menu-shadow-color); + height: $s-4; + left: calc(var(--position) + $s-8); + position: absolute; + width: $s-4; +} + +.gradient-preview-stop-color { + position: absolute; + left: 0; + width: 50%; + height: 100%; + background: var(--color-solid); +} + +.gradient-preview-stop-alpha { + position: absolute; + left: 50%; + width: 50%; + height: 100%; + background: var(--color-alpha); +} + +.gradient-options { + display: flex; + justify-content: space-between; +} + +.gradient-options-buttons { + display: flex; +} + +.gradient-options-select { + width: $s-140; +} + +.rotate-icon { + transform: scaleX(-1); +} + +.gradient-stops-list { + display: flex; + flex-direction: column; + gap: $s-4; + max-height: $s-180; + overflow-y: auto; + overflow-x: hidden; + padding: $s-1 0; +} + +.gradient-stops-entry { + display: flex; + gap: $s-4; + padding: $s-2; + border-radius: $br-12; + border: $s-1 solid transparent; + + &:hover .reorder-icon { + visibility: visible; + } + + &.is-selected { + border-color: var(--color-accent-primary-muted); + } + + &.dnd-over-top .reorder-separator-top { + display: block; + } + + &.dnd-over-bot .reorder-separator-bottom { + display: block; } } -.gradient-stop-color, -.gradient-stop-alpha { - width: 100%; - height: 100%; +.reorder { + cursor: grab; + display: flex; + flex-direction: column; + height: $s-36; + justify-content: center; + left: calc(-1 * $s-2); + margin-top: calc(-1 * $s-2); + position: absolute; + width: $s-16; +} + +.reorder-icon { + height: $s-16; + pointer-events: none; + stroke: var(--color-foreground-secondary); + visibility: hidden; +} + +.reorder-separator-top { + border-color: var(--color-accent-primary); + display: none; + left: 0; + margin-left: $s-12; + margin-top: calc(-1 * $s-6); + position: absolute; + width: calc(100% - $s-24); +} + +.reorder-separator-bottom { + border-color: var(--color-accent-primary); + display: none; + left: 0; + margin-left: $s-12; + margin-top: $s-36; + position: absolute; + width: calc(100% - $s-24); +} + +.offset-input-wrapper { + @extend .input-element; + @include bodySmallTypography; + width: $s-92; +} + +.gradient-separator { + border-color: var(--color-background-quaternary); + border-width: $s-3; + margin-left: -4%; + position: relative; + width: 108%; +} + +.gradient-preview-stop-preview { + background: var(--color-foreground-primary); + border-radius: 50%; + height: $s-4; + left: calc(var(--preview-position, 0%) - $s-2); + pointer-events: none; + position: absolute; + top: calc(50% - $s-2); + width: $s-4; } diff --git a/frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs b/frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs new file mode 100644 index 000000000..8a25ee1d5 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/colorpicker/shortcuts.cljs @@ -0,0 +1,28 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.workspace.colorpicker.shortcuts + (:require + [app.main.data.shortcuts :as ds] + [app.main.data.workspace.colors :as dwc] + [app.main.data.workspace.shortcuts :as wsc] + [app.main.store :as st])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Shortcuts +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; Shortcuts format https://github.com/ccampbell/mousetrap + +(def shortcuts + (merge + wsc/shortcuts + + {:delete-stop {:tooltip (ds/supr) + :command ["del" "backspace"] + :subsections [:edit] + :fn #(st/emit! (dwc/remove-gradient-stop))}})) + 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 59de7fea0..e513f1c0f 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 @@ -46,7 +46,7 @@ (if (= v :multiple) nil v)) (mf/defc color-row - [{:keys [index color disable-gradient disable-opacity disable-image on-change + [{:keys [index color disable-gradient disable-opacity disable-image disable-picker on-change on-reorder on-detach on-open on-close on-remove disable-drag on-focus on-blur select-only select-on-focus]}] (let [current-file-id (mf/use-ctx ctx/current-file-id) @@ -72,8 +72,7 @@ editing-text? (deref editing-text*) opacity? - (and (not gradient-color?) - (not multiple-colors?) + (and (not multiple-colors?) (not library-color?) (not disable-opacity)) @@ -134,7 +133,7 @@ handle-click-color (mf/use-fn - (mf/deps disable-gradient disable-opacity disable-image on-change on-close on-open) + (mf/deps disable-gradient disable-opacity disable-image disable-picker on-change on-close on-open) (fn [color event] (let [color (cond multiple-colors? @@ -166,7 +165,8 @@ (when (fn? on-open) (on-open color)) - (modal/show! :colorpicker props)))) + (when-not disable-picker + (modal/show! :colorpicker props))))) prev-color (h/use-previous color) @@ -187,8 +187,8 @@ :name (str "Color row" index)}) [nil nil])] - (mf/with-effect [color prev-color] - (when (not= prev-color color) + (mf/with-effect [color prev-color disable-picker] + (when (and (not disable-picker) (not= prev-color color)) (modal/update-props! :colorpicker {:data (parse-color color)}))) [:div {:class (stl/css-case diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss index 868d2e569..791fcd052 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss @@ -153,14 +153,6 @@ } } -.gradient-name-wrapper { - border-radius: 0 $br-8 $br-8 0; - .color-name { - @include flexRow; - border-radius: 0 $br-8 $br-8 0; - } -} - .library-name-wrapper { border-radius: $br-8; } diff --git a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs index c035b3134..8adb51820 100644 --- a/frontend/src/app/main/ui/workspace/viewport/gradients.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/gradients.cljs @@ -7,14 +7,18 @@ (ns app.main.ui.workspace.viewport.gradients "Gradients handlers and renders" (:require + [app.common.colors :as cc] [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] + [app.common.geom.shapes.points :as gsp] [app.common.math :as mth] [app.main.data.workspace.colors :as dc] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.workspace.viewport.viewport-ref :as uwvv] [app.util.dom :as dom] [app.util.mouse :as mse] [beicon.v2.core :as rx] @@ -23,21 +27,27 @@ (def gradient-line-stroke-width 2) (def gradient-line-stroke-color "var(--app-white)") -(def gradient-square-width 15) -(def gradient-square-radius 2) +(def gradient-square-width 20.5) +(def gradient-square-radius 4) (def gradient-square-stroke-width 2) -(def gradient-width-handler-radius 5) +(def gradient-width-handler-radius 4) +(def gradient-width-handler-radius-selected 6) +(def gradient-width-handler-radius-handler 15) (def gradient-width-handler-color "var(--app-white)") (def gradient-square-stroke-color "var(--app-white)") (def gradient-square-stroke-color-selected "var(--color-accent-tertiary)") -(mf/defc shadow [{:keys [id x y width height offset]}] +(def gradient-endpoint-radius 4) +(def gradient-endpoint-radius-selected 6) +(def gradient-endpoint-radius-handler 20) + +(mf/defc shadow [{:keys [id offset]}] [:filter {:id id - :x x - :y y - :width width - :height height - :filterUnits "userSpaceOnUse" + :x "-10%" + :y "-10%" + :width "120%" + :height "120%" + :filterUnits "objectBoundingBox" :color-interpolation-filters "sRGB"} [:feFlood {:flood-opacity "0" :result "BackgroundImageFix"}] [:feColorMatrix {:in "SourceAlpha" :type "matrix" :values "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"}] @@ -47,53 +57,22 @@ [:feBlend {:mode "normal" :in2 "BackgroundImageFix" :result id}] [:feBlend {:mode "normal" :in "SourceGraphic" :in2 id :result "shape"}]]) -(mf/defc gradient-line-drop-shadow-filter [{:keys [id zoom from-p to-p]}] - [:& shadow - {:id id - :x (min (- (:x from-p) (/ 2 zoom)) - (- (:x to-p) (/ 2 zoom))) - :y (min (- (:y from-p) (/ 2 zoom)) - (- (:y to-p) (/ 2 zoom))) - :width (+ (mth/abs (- (:x to-p) (:x from-p))) (/ 4 zoom)) - :height (+ (mth/abs (- (:y to-p) (:y from-p))) (/ 4 zoom)) - :offset (/ 2 zoom)}]) - - -(mf/defc gradient-square-drop-shadow-filter [{:keys [id zoom point]}] - [:& shadow - {:id id - :x (- (:x point) (/ gradient-square-width zoom 2) 2) - :y (- (:y point) (/ gradient-square-width zoom 2) 2) - :width (+ (/ gradient-square-width zoom) (/ 2 zoom) 4) - :height (+ (/ gradient-square-width zoom) (/ 2 zoom) 4) - :offset (/ 2 zoom)}]) - -(mf/defc gradient-width-handler-shadow-filter [{:keys [id zoom point]}] - [:& shadow - {:id id - :x (- (:x point) (/ gradient-width-handler-radius zoom) 2) - :y (- (:y point) (/ gradient-width-handler-radius zoom) 2) - :width (+ (/ (* 2 gradient-width-handler-radius) zoom) (/ 2 zoom) 4) - :height (+ (/ (* 2 gradient-width-handler-radius) zoom) (/ 2 zoom) 4) - :offset (/ 2 zoom)}]) - (def checkerboard "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAIAAAC0tAIdAAACvUlEQVQoFQGyAk39AeLi4gAAAAAAAB0dHQAAAAAAAOPj4wAAAAAAAB0dHQAAAAAAAOPj4wAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB////AAAAAAAA4+PjAAAAAAAAHR0dAAAAAAAA4+PjAAAAAAAAHR0dAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATj4+MAAAAAAAAdHR0AAAAAAADj4+MAAAAAAAAdHR0AAAAAAADj4+MAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjScaa0cU7nIAAAAASUVORK5CYII=") -#_(def checkerboard "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAADFJREFUOE9jZGBgEAFifOANPknGUQMYhkkYEEgG+NMJKAwIAbwJbdQABnBCIgRoG4gAIF8IsXB/Rs4AAAAASUVORK5CYII=") - (mf/defc gradient-color-handler - [{:keys [filter-id zoom point color angle selected - on-click on-pointer-down on-pointer-up]}] - [:g {:filter (str/fmt "url(#%s)" filter-id) + [{:keys [zoom point color angle selected index + on-click on-pointer-down on-pointer-up on-pointer-move on-lost-pointer-capture]}] + [:g {:filter "url(#gradient-drop-shadow)" + :style {:cursor "pointer"} :transform (gmt/rotate-matrix angle point)} [:image {:href checkerboard - :x (- (:x point) (/ gradient-square-width 2 zoom)) + :x (+ (- (:x point) (/ gradient-square-width 2 zoom)) (/ 12 zoom)) :y (- (:y point) (/ gradient-square-width 2 zoom)) :width (/ gradient-square-width zoom) :height (/ gradient-square-width zoom)}] - [:rect {:x (- (:x point) (/ gradient-square-width 2 zoom)) + [:rect {:x (+ (- (:x point) (/ gradient-square-width 2 zoom)) (/ 12 zoom)) :y (- (:y point) (/ gradient-square-width 2 zoom)) :rx (/ gradient-square-radius zoom) :width (/ gradient-square-width zoom 2) @@ -103,40 +82,60 @@ :on-pointer-down (partial on-pointer-down :to-p) :on-pointer-up (partial on-pointer-up :to-p)}] + (when selected + [:rect {:pointer-events "none" + :x (- (+ (- (:x point) (/ gradient-square-width 2 zoom)) (/ 12 zoom)) (/ 2 zoom)) + :y (- (- (:y point) (/ gradient-square-width 2 zoom)) (/ 2 zoom)) + :rx (/ (+ gradient-square-radius (/ 2 zoom)) zoom) + :width (+ (/ gradient-square-width zoom) (/ 4 zoom)) + :height (+ (/ gradient-square-width zoom) (/ 4 zoom)) + :stroke "var(--color-accent-tertiary)" + :stroke-width (/ gradient-square-stroke-width zoom) + :fill "transparent"}]) + [:rect {:data-allow-click-modal "colorpicker" - :x (- (:x point) (/ gradient-square-width 2 zoom)) + :data-index index + :pointer-events "all" + :x (+ (- (:x point) (/ gradient-square-width 2 zoom)) (/ 12 zoom)) :y (- (:y point) (/ gradient-square-width 2 zoom)) :rx (/ gradient-square-radius zoom) :width (/ gradient-square-width zoom) :height (/ gradient-square-width zoom) - :stroke (if selected "var(--color-accent-tertiary)" "var(--app-white)") + :stroke "var(--app-white)" :stroke-width (/ gradient-square-stroke-width zoom) :fill (:value color) :fill-opacity (:opacity color) :on-click on-click :on-pointer-down on-pointer-down - :on-pointer-up on-pointer-up}]]) + :on-pointer-up on-pointer-up + :on-pointer-move on-pointer-move + :on-lost-pointer-capture on-lost-pointer-capture}] + + [:circle {:cx (:x point) + :cy (:y point) + :r (/ 2 zoom) + :fill "var(--app-white)"}]]) (mf/defc gradient-handler-transformed - [{:keys [from-p to-p width-p from-color to-color zoom editing + [{:keys [from-p + to-p + width-p + zoom + editing + stops on-change-start on-change-finish on-change-width]}] (let [moving-point (mf/use-var nil) - angle (+ 90 (gpt/angle from-p to-p)) + angle (+ 90 (gpt/angle from-p to-p)) + dragging-ref (mf/use-ref false) + start-offset (mf/use-ref nil) - on-click - (fn [position event] - (dom/stop-propagation event) - (dom/prevent-default event) - (when (#{:from-p :to-p} position) - (st/emit! (dc/select-colorpicker-gradient-stop - (case position - :from-p 0 - :to-p 1))))) - - on-pointer-down + handler-state (mf/use-state {:display? false :offset 0 :hover nil}) + + endpoint-on-pointer-down (fn [position event] (dom/stop-propagation event) (dom/prevent-default event) + (dom/capture-pointer event) (reset! moving-point position) (when (#{:from-p :to-p} position) (st/emit! (dc/select-colorpicker-gradient-stop @@ -144,11 +143,102 @@ :from-p 0 :to-p 1))))) - on-pointer-up + endpoint-on-pointer-up (fn [_position event] + (dom/release-pointer event) (dom/stop-propagation event) (dom/prevent-default event) - (reset! moving-point nil))] + (reset! moving-point nil) + (swap! handler-state assoc :hover nil)) + + endpoint-on-pointer-enter + (mf/use-fn + (fn [position] + (swap! handler-state assoc :hover position))) + + endpoint-on-pointer-leave + (mf/use-fn + (fn [_] + (swap! handler-state assoc :hover nil))) + + points-on-pointer-enter + (mf/use-fn + (fn [] + (swap! handler-state assoc :display? true))) + + points-on-pointer-leave + (mf/use-fn + (fn [] + (swap! handler-state assoc :display? false))) + + points-on-pointer-down + (mf/use-fn + (mf/deps stops) + (fn [e] + (dom/prevent-default e) + (dom/stop-propagation e) + + (let [raw-pt (dom/get-client-position e) + position (uwvv/point->viewport raw-pt) + lv (-> (gpt/to-vec from-p to-p) (gpt/unit)) + nv (gpt/normal-left lv) + offset (-> (gsp/project-t position [from-p to-p] nv) + (mth/precision 2)) + new-stop (cc/interpolate-gradient stops offset) + stops (conj stops new-stop) + stops (->> stops (sort-by :offset) (into []))] + (st/emit! (dc/update-colorpicker-stops stops))))) + + points-on-pointer-move + (mf/use-fn + (mf/deps from-p to-p) + (fn [e] + (let [raw-pt (dom/get-client-position e) + position (uwvv/point->viewport raw-pt) + lv (-> (gpt/to-vec from-p to-p) (gpt/unit)) + nv (gpt/normal-left lv) + offset (gsp/project-t position [from-p to-p] nv)] + (swap! handler-state assoc :offset offset)))) + + handle-marker-pointer-down + (mf/use-fn + (mf/deps stops) + (fn [event] + + (let [index (-> event dom/get-current-target (dom/get-data "index") d/read-string) + stop (get stops index)] + (dom/capture-pointer event) + (st/emit! (dc/select-colorpicker-gradient-stop index)) + (mf/set-ref-val! dragging-ref true) + (mf/set-ref-val! start-offset (:offset stop))))) + + handle-marker-pointer-move + (mf/use-fn + (mf/deps stops) + (fn [event] + (when-let [_ (mf/ref-val dragging-ref)] + (let [index (-> event dom/get-target (dom/get-data "index") d/read-string) + + raw-pt (dom/get-client-position event) + position (uwvv/point->viewport raw-pt) + lv (-> (gpt/to-vec from-p to-p) (gpt/unit)) + nv (gpt/normal-left lv) + offset (gsp/project-t position [from-p to-p] nv) + offset (mth/precision (mth/clamp offset 0 1) 2)] + + (st/emit! (dc/update-colorpicker-stops (assoc-in stops [index :offset] offset))))))) + + handle-marker-lost-pointer-capture + (mf/use-fn + (mf/deps stops) + (fn [event] + (dom/release-pointer event) + (mf/set-ref-val! dragging-ref false) + (mf/set-ref-val! start-offset nil) + (let [stops (->> stops + (sort-by :offset) + (into []))] + (st/emit! (dc/update-colorpicker-stops stops)))))] (mf/use-effect (mf/deps @moving-point from-p to-p width-p) @@ -171,73 +261,193 @@ (on-change-width new-width-p))) nil))))] (fn [] (rx/dispose! subs))))) - [:g.gradient-handlers + + [:g.gradient-handlers {:pointer-events "none"} [:defs - [:& gradient-line-drop-shadow-filter {:id "gradient-line-drop-shadow" :from-p from-p :to-p to-p :zoom zoom}] - [:& gradient-line-drop-shadow-filter {:id "gradient-width-line-drop-shadow" :from-p from-p :to-p width-p :zoom zoom}] - [:& gradient-square-drop-shadow-filter {:id "gradient-square-from-drop-shadow" :point from-p :zoom zoom}] - [:& gradient-square-drop-shadow-filter {:id "gradient-square-to-drop-shadow" :point to-p :zoom zoom}] - [:& gradient-width-handler-shadow-filter {:id "gradient-width-handler-drop-shadow" :point width-p :zoom zoom}]] + [:& shadow {:id "gradient-drop-shadow" :offset (/ 2 zoom)}]] - [:g {:filter "url(#gradient-line-drop-shadow)"} - [:line {:x1 (:x from-p) - :y1 (:y from-p) - :x2 (:x to-p) - :y2 (:y to-p) - :stroke gradient-line-stroke-color - :stroke-width (/ gradient-line-stroke-width zoom)}]] + (let [lv (-> (gpt/to-vec from-p to-p) + (gpt/unit)) + nv (gpt/normal-left lv) + width (/ 40 zoom) + points [(gpt/add from-p (gpt/scale nv (/ width -2))) + (gpt/add from-p (gpt/scale nv (/ width 2))) + (gpt/add to-p (gpt/scale nv (/ width 2))) + (gpt/add to-p (gpt/scale nv (/ width -2)))] + points-str + (->> points + (map #(dm/str (:x %) "," (:y %))) + (str/join ", "))] + + [:polygon {:points points-str + :data-allow-click-modal "colorpicker" + :fill "transparent" + :pointer-events "all" + :on-pointer-enter points-on-pointer-enter + :on-pointer-leave points-on-pointer-leave + :on-pointer-down points-on-pointer-down + :on-pointer-move points-on-pointer-move}]) + + [:g {:filter "url(#gradient-drop-shadow)"} + (let [pu + (-> (gpt/to-vec from-p to-p) + (gpt/normal-right) + (gpt/unit)) + + sc (/ gradient-line-stroke-width zoom 2) + + points + [(gpt/add from-p (gpt/scale pu (- sc))) + (gpt/add from-p (gpt/scale pu sc)) + (gpt/add to-p (gpt/scale pu sc)) + (gpt/add to-p (gpt/scale pu (- sc)))]] + ;; Use the polygon shape instead of lines because horizontal/vertical lines won't work + ;; with shadows + [:polygon + {:points + (->> points + (map #(dm/fmt "%, %" (:x %) (:y %))) + (str/join " ")) + + :fill gradient-line-stroke-color}])] (when width-p - [:g {:filter "url(#gradient-width-line-drop-shadow)"} - [:line {:x1 (:x from-p) - :y1 (:y from-p) - :x2 (:x width-p) - :y2 (:y width-p) - :stroke gradient-line-stroke-color - :stroke-width (/ gradient-line-stroke-width zoom)}]]) + [:g {:filter "url(#gradient-drop-shadow)"} + (let [pu + (-> (gpt/to-vec from-p width-p) + (gpt/normal-right) + (gpt/unit)) + + sc (/ gradient-line-stroke-width zoom 2) + + points + [(gpt/add from-p (gpt/scale pu (- sc))) + (gpt/add from-p (gpt/scale pu sc)) + (gpt/add width-p (gpt/scale pu sc)) + (gpt/add width-p (gpt/scale pu (- sc)))]] + ;; Use the polygon shape instead of lines because horizontal/vertical lines won't work + ;; with shadows + [:polygon {:points + (->> points + (map #(dm/fmt "%, %" (:x %) (:y %))) + (str/join " ")) + + :fill gradient-line-stroke-color}])]) (when width-p - [:g {:filter "url(#gradient-width-handler-drop-shadow)"} + [:g {:filter "url(#gradient-drop-shadow)"} + (when (= :width-p (:hover @handler-state)) + [:circle {:filter "url(#gradient-drop-shadow)" + :cx (:x width-p) + :cy (:y width-p) + :fill gradient-square-stroke-color-selected + :r (/ gradient-width-handler-radius-selected zoom)}]) + [:circle {:data-allow-click-modal "colorpicker" :cx (:x width-p) :cy (:y width-p) :r (/ gradient-width-handler-radius zoom) - :fill gradient-width-handler-color - :on-pointer-down (partial on-pointer-down :width-p) - :on-pointer-up (partial on-pointer-up :width-p)}]]) + :fill gradient-width-handler-color}] - [:& gradient-color-handler - {:selected (or (not editing) (= editing 0)) - :filter-id "gradient-square-from-drop-shadow" - :zoom zoom - :point from-p - :color from-color - :angle angle - :on-click (partial on-click :from-p) - :on-pointer-down (partial on-pointer-down :from-p) - :on-pointer-up (partial on-pointer-up :from-p)}] + [:circle {:data-allow-click-modal "colorpicker" + :pointer-events "all" + :cx (:x width-p) + :cy (:y width-p) + :r (/ gradient-width-handler-radius-handler zoom) + :fill "transpgarent" + :on-pointer-down (partial endpoint-on-pointer-down :width-p) + :on-pointer-enter (partial endpoint-on-pointer-enter :width-p) + :on-pointer-leave (partial endpoint-on-pointer-leave :width-p) + :on-pointer-up (partial endpoint-on-pointer-up :width-p)}]]) - [:& gradient-color-handler - {:selected (= editing 1) - :filter-id "gradient-square-to-drop-shadow" - :zoom zoom - :point to-p - :color to-color - :angle angle - :on-click (partial on-click :to-p) - :on-pointer-down (partial on-pointer-down :to-p) - :on-pointer-up (partial on-pointer-up :to-p)}]])) + [:g + (when (= :from-p (:hover @handler-state)) + [:circle {:filter "url(#gradient-drop-shadow)" + :cx (:x from-p) + :cy (:y from-p) + :fill gradient-square-stroke-color-selected + :r (/ gradient-endpoint-radius-selected zoom)}]) + + [:circle {:filter "url(#gradient-drop-shadow)" + :cx (:x from-p) + :cy (:y from-p) + :fill "var(--app-white)" + :r (/ gradient-endpoint-radius zoom)}] + + [:circle {:data-allow-click-modal "colorpicker" + :pointer-events "all" + :cx (:x from-p) + :cy (:y from-p) + :fill "transparent" + :r (/ gradient-endpoint-radius-handler zoom) + :on-pointer-down (partial endpoint-on-pointer-down :from-p) + :on-pointer-up (partial endpoint-on-pointer-up :from-p) + :on-pointer-enter (partial endpoint-on-pointer-enter :from-p) + :on-pointer-leave (partial endpoint-on-pointer-leave :from-p) + :on-lost-pointer-capture (partial endpoint-on-pointer-up :from-p)}]] + + [:g + (when (= :to-p (:hover @handler-state)) + [:circle {:filter "url(#gradient-drop-shadow)" + :cx (:x to-p) + :cy (:y to-p) + :fill gradient-square-stroke-color-selected + :r (/ gradient-endpoint-radius-selected zoom)}]) + + [:circle {:filter "url(#gradient-drop-shadow)" + :cx (:x to-p) + :cy (:y to-p) + :fill "var(--app-white)" + :r (/ gradient-endpoint-radius zoom)}] + + [:circle {:data-allow-click-modal "colorpicker" + :pointer-events "all" + :cx (:x to-p) + :cy (:y to-p) + :fill "transparent" + :r (/ gradient-endpoint-radius-handler zoom) + :on-pointer-down (partial endpoint-on-pointer-down :to-p) + :on-pointer-up (partial endpoint-on-pointer-up :to-p) + :on-pointer-enter (partial endpoint-on-pointer-enter :to-p) + :on-pointer-leave (partial endpoint-on-pointer-leave :to-p) + :on-lost-pointer-capture (partial endpoint-on-pointer-up :from-p)}]] + + (for [[index stop] (d/enumerate stops)] + (let [stop-p + (gpt/add + from-p + (-> (gpt/to-vec from-p to-p) + (gpt/scale (:offset stop))))] + + [:& gradient-color-handler + {:key index + :selected (= editing index) + :zoom zoom + :point stop-p + :color {:value (:color stop) :opacity (:opacity stop)} + :angle angle + :index index + :on-pointer-down handle-marker-pointer-down + :on-pointer-move handle-marker-pointer-move + :on-lost-pointer-capture handle-marker-lost-pointer-capture}])) + + (when (:display? @handler-state) + (let [p (gpt/add from-p + (-> (gpt/to-vec from-p to-p) + (gpt/scale (:offset @handler-state))))] + [:circle {:filter "url(#gradient-drop-shadow)" + :cx (:x p) + :cy (:y p) + :r (/ 4 zoom) + :fill "var(--app-white)"}]))])) (mf/defc gradient-handlers* - [{:keys [zoom stops gradient editing-stop shape]}] + [{:keys [zoom stops gradient editing shape] :as kk}] (let [transform (gsh/transform-matrix shape) transform-inverse (gsh/inverse-transform-matrix shape) {:keys [x y width height] :as sr} (:selrect shape) - [{start-color :color start-opacity :opacity} - {end-color :color end-opacity :opacity}] stops - from-p (-> (gpt/point (+ x (* width (:start-x gradient))) (+ y (* height (:start-y gradient)))) (gpt/transform transform)) @@ -249,7 +459,7 @@ gradient-length (gpt/length gradient-vec) width-v (-> gradient-vec - (gpt/normal-left) + (gpt/normal-right) (gpt/multiply (gpt/point (* (:width gradient) (/ gradient-length (/ height 2))))) (gpt/multiply (gpt/point (/ width 2)))) @@ -289,12 +499,11 @@ (change! {:width norm-dist})))))] [:& gradient-handler-transformed - {:editing editing-stop + {:editing editing :from-p from-p :to-p to-p :width-p (when (= :radial (:type gradient)) width-p) - :from-color {:value start-color :opacity start-opacity} - :to-color {:value end-color :opacity end-opacity} + :stops stops :zoom zoom :on-change-start on-change-start :on-change-finish on-change-finish @@ -305,17 +514,15 @@ [{:keys [id zoom]}] (let [shape-ref (mf/use-memo (mf/deps id) #(refs/object-by-id id)) shape (mf/deref shape-ref) - state (mf/deref refs/colorpicker) gradient (:gradient state) stops (:stops state) editing-stop (:editing-stop state)] - (when (and (some? gradient) - (= id (:shape-id gradient))) + (when (and (some? gradient) (= id (:shape-id gradient))) [:& gradient-handlers* {:zoom zoom :gradient gradient :stops stops - :editing-stop editing-stop + :editing editing-stop :shape shape}]))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 0f46d1bca..edc575544 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2146,7 +2146,6 @@ msgstr "YouTube" msgid "media.choose-image" msgstr "Choose image" -#, unused msgid "media.gradient" msgstr "Gradient"