diff --git a/CHANGES.md b/CHANGES.md index dc1616d59c..afb57f166a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -35,6 +35,7 @@ - Access Tokens look & feel refinement [Taiga #13114](https://tree.taiga.io/project/penpot/us/13114) - Enhance readability of applied tokens in plugins API [Taiga #13714](https://tree.taiga.io/project/penpot/issue/13714) +- Add drag-to-change for numeric inputs in workspace sidebar [Github #2466](https://github.com/penpot/penpot/issues/2466) ### :bug: Bugs fixed diff --git a/frontend/resources/styles/common/base.scss b/frontend/resources/styles/common/base.scss index 0d61ab7ebb..eea8d93655 100644 --- a/frontend/resources/styles/common/base.scss +++ b/frontend/resources/styles/common/base.scss @@ -27,6 +27,13 @@ body { width: 100vw; height: 100vh; overflow: hidden; + + &.cursor-drag-scrub { + cursor: ew-resize !important; + * { + cursor: ew-resize !important; + } + } } #app { diff --git a/frontend/resources/styles/common/refactor/basic-rules.scss b/frontend/resources/styles/common/refactor/basic-rules.scss index c82907a5b6..07f6b82dc9 100644 --- a/frontend/resources/styles/common/refactor/basic-rules.scss +++ b/frontend/resources/styles/common/refactor/basic-rules.scss @@ -338,6 +338,12 @@ background-color: var(--input-background-color); border: $s-1 solid var(--input-border-color); color: var(--input-foreground-color); + &:not(:focus-within) { + cursor: ew-resize; + input { + cursor: ew-resize; + } + } span, label { @extend .input-label; diff --git a/frontend/src/app/main/ui/components/numeric_input.cljs b/frontend/src/app/main/ui/components/numeric_input.cljs index 510738b0bd..796f79e40e 100644 --- a/frontend/src/app/main/ui/components/numeric_input.cljs +++ b/frontend/src/app/main/ui/components/numeric_input.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.components.numeric-input (:require [app.common.data :as d] + [app.common.math :as mth] [app.common.schema :as sm] [app.main.ui.formats :as fmt] [app.main.ui.hooks :as h] @@ -61,6 +62,11 @@ ;; Last value input by the user we need to store to save on unmount last-value* (mf/use-var value) + ;; Drag scrubbing state + drag-state* (mf/use-ref :idle) + drag-start-x* (mf/use-ref 0) + drag-start-val* (mf/use-ref 0) + parse-value (mf/use-fn (mf/deps min-value max-value value nillable? default) @@ -213,16 +219,80 @@ (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-not (= :dragging (mf/ref-val drag-state*)) + (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? - (dom/select-text! target) - ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect - (.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))) + (when select-on-focus? + (dom/select-text! target) + ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect + (.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))) + + on-scrub-pointer-down + (mf/use-fn + (mf/deps value value-str min-value max-value default) + (fn [event] + (let [disabled? (unchecked-get props "disabled") + node (mf/ref-val ref) + is-focused (and (some? node) (dom/active? node))] + (when-not (or disabled? is-focused (= :multiple value-str)) + (let [client-x (.-clientX event) + start-val (or value default 0)] + (mf/set-ref-val! drag-state* :maybe-dragging) + (mf/set-ref-val! drag-start-x* client-x) + (mf/set-ref-val! drag-start-val* start-val) + (dom/capture-pointer event)))))) + + on-scrub-pointer-move + (mf/use-fn + (mf/deps apply-value update-input step-value min-value max-value) + (fn [event] + (let [state (mf/ref-val drag-state*)] + (when (or (= state :maybe-dragging) (= state :dragging)) + (let [client-x (.-clientX event) + start-x (mf/ref-val drag-start-x*) + delta-x (- client-x start-x)] + (when (and (= state :maybe-dragging) + (>= (js/Math.abs delta-x) 3)) + (mf/set-ref-val! drag-state* :dragging) + (dom/add-class! (dom/get-body) "cursor-drag-scrub")) + (when (= (mf/ref-val drag-state*) :dragging) + (let [effective-step (cond + (.-shiftKey event) (* step-value 10) + (.-ctrlKey event) (* step-value 0.1) + :else step-value) + steps (js/Math.round (/ delta-x 1)) + new-val (+ (mf/ref-val drag-start-val*) + (* steps effective-step)) + new-val (cond-> new-val + (d/num? min-value) (mth/max min-value) + (d/num? max-value) (mth/min max-value))] + (update-input new-val) + (apply-value event new-val)))))))) + + on-scrub-pointer-up + (mf/use-fn + (mf/deps ref) + (fn [event] + (let [state (mf/ref-val drag-state*)] + (when (= state :maybe-dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/release-pointer event) + (when-let [node (mf/ref-val ref)] + (dom/focus! node))) + (when (= state :dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/remove-class! (dom/get-body) "cursor-drag-scrub") + (dom/release-pointer event))))) + + on-scrub-lost-pointer-capture + (mf/use-fn + (fn [_event] + (mf/set-ref-val! drag-state* :idle) + (dom/remove-class! (dom/get-body) "cursor-drag-scrub"))) props (-> (obj/clone props) (obj/unset! "selectOnFocus") @@ -236,7 +306,11 @@ (obj/set! "title" title) (obj/set! "onKeyDown" handle-key-down) (obj/set! "onBlur" handle-blur) - (obj/set! "onFocus" handle-focus))] + (obj/set! "onFocus" handle-focus) + (obj/set! "onPointerDown" on-scrub-pointer-down) + (obj/set! "onPointerMove" on-scrub-pointer-move) + (obj/set! "onPointerUp" on-scrub-pointer-up) + (obj/set! "onLostPointerCapture" on-scrub-lost-pointer-capture))] (mf/with-effect [value] (when-let [input-node (mf/ref-val ref)] diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index a98dba1c8d..5ad6492d1e 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -136,6 +136,8 @@ [:applied-token {:optional true} [:maybe [:or :string [:= :multiple]]]] [:empty-to-end {:optional true} :boolean] [:on-change {:optional true} fn?] + [:on-change-start {:optional true} fn?] + [:on-change-end {:optional true} fn?] [:on-blur {:optional true} fn?] [:on-focus {:optional true} fn?] [:on-detach {:optional true} fn?] @@ -151,7 +153,8 @@ min max max-length step is-selected-on-focus nillable tokens applied-token empty-to-end - on-change on-blur on-focus on-detach + on-change on-change-start on-change-end + on-blur on-focus on-detach property align ref name tooltip-placement text-icon] :rest props}] @@ -222,6 +225,11 @@ open-dropdown-ref (mf/use-ref nil) token-detach-btn-ref (mf/use-ref nil) + ;; Drag scrubbing state + drag-state* (mf/use-ref :idle) + drag-start-x* (mf/use-ref 0) + drag-start-val* (mf/use-ref 0) + dropdown-options (mf/with-memo [tokens filter-id] (csu/get-token-dropdown-options tokens filter-id)) @@ -442,13 +450,14 @@ (mf/use-fn (mf/deps on-focus select-on-focus) (fn [event] - (when (fn? on-focus) - (on-focus event)) - (let [target (dom/get-target event)] - (when select-on-focus - (dom/select-text! target) - ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect - (.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))) + (when-not (= :dragging (mf/ref-val drag-state*)) + (when (fn? on-focus) + (on-focus event)) + (let [target (dom/get-target event)] + (when select-on-focus + (dom/select-text! target) + ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect + (.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))) on-mouse-wheel (mf/use-fn @@ -468,6 +477,77 @@ (dom/stop-propagation event) (apply-value (dm/str new-val))))))) + on-scrub-pointer-down + (mf/use-fn + (mf/deps disabled is-open is-multiple? ref min max nillable default) + (fn [event] + (when-not (or disabled is-open is-multiple?) + (let [node (mf/ref-val ref) + is-focused (and (some? node) (dom/active? node)) + has-token (some? (deref token-applied*))] + (when-not (or is-focused has-token) + (let [client-x (.-clientX event) + parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable) + start-val (or parsed default 0)] + (mf/set-ref-val! drag-state* :maybe-dragging) + (mf/set-ref-val! drag-start-x* client-x) + (mf/set-ref-val! drag-start-val* start-val) + (dom/capture-pointer event))))))) + + on-scrub-pointer-move + (mf/use-fn + (mf/deps apply-value update-input step min max on-change-start) + (fn [event] + (let [state (mf/ref-val drag-state*)] + (when (or (= state :maybe-dragging) (= state :dragging)) + (let [client-x (.-clientX event) + start-x (mf/ref-val drag-start-x*) + delta-x (- client-x start-x)] + (when (and (= state :maybe-dragging) + (>= (js/Math.abs delta-x) 3)) + (mf/set-ref-val! drag-state* :dragging) + (dom/add-class! (dom/get-body) "cursor-drag-scrub") + (when (fn? on-change-start) + (on-change-start))) + (when (= (mf/ref-val drag-state*) :dragging) + (let [effective-step (cond + (.-shiftKey event) (* step 10) + (.-ctrlKey event) (* step 0.1) + :else step) + steps (js/Math.round (/ delta-x 1)) + new-val (mth/clamp (+ (mf/ref-val drag-start-val*) + (* steps effective-step)) + min max)] + (update-input (fmt/format-number new-val)) + (apply-value (dm/str new-val))))))))) + + on-scrub-pointer-up + (mf/use-fn + (mf/deps ref on-change-end) + (fn [event] + (let [state (mf/ref-val drag-state*)] + (when (= state :maybe-dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/release-pointer event) + (when-let [node (mf/ref-val ref)] + (dom/focus! node))) + (when (= state :dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/remove-class! (dom/get-body) "cursor-drag-scrub") + (dom/release-pointer event) + (when (fn? on-change-end) + (on-change-end)))))) + + on-scrub-lost-pointer-capture + (mf/use-fn + (mf/deps on-change-end) + (fn [_event] + (let [was-dragging (= :dragging (mf/ref-val drag-state*))] + (mf/set-ref-val! drag-state* :idle) + (dom/remove-class! (dom/get-body) "cursor-drag-scrub") + (when (and was-dragging (fn? on-change-end)) + (on-change-end))))) + open-dropdown (mf/use-fn (mf/deps disabled ref) @@ -658,7 +738,11 @@ (mf/set-ref-val! options-ref dropdown-options)) [:div {:class [class (stl/css :input-wrapper)] - :ref wrapper-ref} + :ref wrapper-ref + :on-pointer-down on-scrub-pointer-down + :on-pointer-move on-scrub-pointer-move + :on-pointer-up on-scrub-pointer-up + :on-lost-pointer-capture on-scrub-lost-pointer-capture} (if (and (some? token-applied) (not= :multiple token-applied)) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.scss b/frontend/src/app/main/ui/ds/controls/numeric_input.scss index bbec005618..b45ff9fe15 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.scss +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.scss @@ -20,6 +20,9 @@ inline-size: 100%; position: relative; + &:not(:focus-within) { + cursor: ew-resize; + } &:hover { --opacity-button: 1; } 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 8e88e54ed6..42f5dceefe 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 @@ -184,6 +184,9 @@ @include t.use-typography("body-small"); display: flex; align-items: center; + &:not(:focus-within) { + cursor: ew-resize; + } block-size: $sz-32; inline-size: px2rem(60); padding-inline-start: var(--sp-xs); @@ -240,6 +243,10 @@ margin: var(--sp-xxs) 0; padding: 0 0 0 px2rem(6); color: var(--color-foreground-primary); + cursor: ew-resize; + &:focus { + cursor: text; + } &[disabled] { opacity: 0.5; pointer-events: none;