From def3dfb2335e614bd6e2d53445bda003f71ea688 Mon Sep 17 00:00:00 2001 From: scheinriese Date: Sun, 8 Mar 2026 20:49:03 +0100 Subject: [PATCH] Implement V3 shortcut customization popover with two-tier conflict detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the shortcut customization UI from a modal dialog to an inline popover with a state machine (idle → recording → accepted/conflict). Add cross-context conflict awareness with amber warnings for non-blocking conflicts across handler groups, while keeping red blocking conflicts for same-context collisions. Redesign Esc/Backspace semantics for consistency: Esc always closes, Backspace always removes. Add Radix tooltip on Reassign button and polish spacing, animations, and feedback banners. Co-Authored-By: Claude Opus 4.6 --- deps/shui/src/logseq/shui/shortcut.cljs | 11 +- src/main/frontend/components/shortcut.cljs | 530 ++++++++++++++---- src/main/frontend/components/shortcut.css | 234 +++++++- src/main/frontend/modules/shortcut/core.cljs | 15 +- .../modules/shortcut/data_helper.cljs | 65 +++ 5 files changed, 695 insertions(+), 160 deletions(-) diff --git a/deps/shui/src/logseq/shui/shortcut.cljs b/deps/shui/src/logseq/shui/shortcut.cljs index ac0b2b2242..61357856d6 100644 --- a/deps/shui/src/logseq/shui/shortcut.cljs +++ b/deps/shui/src/logseq/shui/shortcut.cljs @@ -52,8 +52,9 @@ ("page-up") "" ("page-down") "" ("esc" "escape") "Esc" - ("backspace") "Backspace" + ("backspace") "⌫" ("delete") "Delete" + ("caps-lock" "capslock") "⇪" (nil) "" (name key))) ;; If result is a single letter (a-z), uppercase it @@ -310,7 +311,13 @@ (first binding) ; combo: nested collection like [["shift" "cmd"]] (coll? binding) - (let [flattened (mapcat #(if (coll? %) % [%]) binding)] + (let [flattened (mapcat (fn [item] + (cond + (coll? item) item + (and (string? item) (string/includes? item "+")) + (string/split item #"\+") ; split combo strings like "meta+caps-lock" + :else [item])) + binding)] (if (every? string? flattened) flattened ; separate: flat collection like ["cmd" "k"] or ["⇧" "g"] (map str flattened))) ; convert any non-strings to strings diff --git a/src/main/frontend/components/shortcut.cljs b/src/main/frontend/components/shortcut.cljs index fa211826b9..eb84b8170c 100644 --- a/src/main/frontend/components/shortcut.cljs +++ b/src/main/frontend/components/shortcut.cljs @@ -2,7 +2,6 @@ (:require [cljs-bean.core :as bean] [clojure.string :as string] [frontend.context.i18n :refer [t]] - [frontend.handler.notification :as notification] [frontend.modules.shortcut.config :as shortcut-config] [frontend.modules.shortcut.core :as shortcut] [frontend.modules.shortcut.data-helper :as dh] @@ -12,7 +11,6 @@ [frontend.ui :as ui] [frontend.util :as util] [goog.events :as events] - [logseq.shui.dialog.core :as shui-dialog] [logseq.shui.hooks :as hooks] [logseq.shui.ui :as shui] [promesa.core :as p] @@ -33,7 +31,6 @@ (defonce *refresh-sentry (atom 0)) (defn refresh-shortcuts-list! [] (reset! *refresh-sentry (inc @*refresh-sentry))) (defonce *global-listener-setup? (atom false)) -(defonce *customize-modal-life-sentry (atom 0)) (defn- to-vector [v] (when-not (nil? v) @@ -159,21 +156,44 @@ [:span.px-1 (dh/get-shortcut-desc (assoc binding-map :id id))] (when plugin? [:code plugin-id])]))) +(defonce *active-shortcut-id (atom nil)) + (defn- open-customize-shortcut-dialog! - [id] + [^js anchor-el id] (when-let [{:keys [binding user-binding] :as m} (dh/shortcut-item id)] (let [binding (to-vector binding) user-binding (and user-binding (to-vector user-binding)) - modal-id (str :customize-shortcut id) - label (shortcut-desc-label id m) + popup-id (keyword (str "customize-shortcut-" (name id))) + label (dh/get-shortcut-desc (assoc m :id id)) + close-fn! #(do (reset! *active-shortcut-id nil) + (shui/popup-hide! popup-id)) args [id label binding user-binding {:saved-cb (fn [] (-> (p/delay 500) (p/then refresh-shortcuts-list!))) - :modal-id modal-id}]] - (shui/dialog-open! - (fn [] (apply customize-shortcut-dialog-inner args)) - {:id modal-id - :class "w-auto md:max-w-2xl" - :payload args})))) + :close-fn close-fn!}]] + ;; Close any previously open shortcut popover + (when-let [prev-id @*active-shortcut-id] + (let [prev-popup-id (keyword (str "customize-shortcut-" (name prev-id)))] + (shui/popup-hide! prev-popup-id))) + (reset! *active-shortcut-id id) + (shui/popup-show! + anchor-el + (fn [_] (apply customize-shortcut-dialog-inner args)) + {:id popup-id + :force-popover? true + :align "start" + :on-after-hide #(reset! *active-shortcut-id nil) + :content-props + {:class "p-0 w-auto" + :collision-padding 12 + :onOpenAutoFocus #(.preventDefault %) + :onCloseAutoFocus #(.preventDefault %) + :onEscapeKeyDown (fn [_] false) + :onPointerDownOutside + (fn [^js e] + (when-let [target (some-> e .-detail .-originalEvent .-target)] + (when (.closest target ".shortcut-row.active") + (.preventDefault e) + false)))}})))) (rum/defc shortcut-conflicts-display [_k conflicts-map] @@ -194,7 +214,7 @@ [:li {:key (str id')} [:a.select-none.hover:underline - {:on-click #(open-customize-shortcut-dialog! id') + {:on-click (fn [^js e] (open-customize-shortcut-dialog! e id')) :title (str handler-id)} [:code.inline-block.mr-1.text-xs (shortcut-utils/decorate-binding k)] @@ -203,51 +223,196 @@ (ui/icon "external-link" {:size 18})] [:code [:small (str id')]]]]))]])]) +(defn- execute-undo! + "Restore previous bindings for all affected actions." + [snapshot] + (doseq [{:keys [action-id previous-binding]} (:entries snapshot)] + (shortcut/persist-user-shortcut! action-id previous-binding)) + (js/setTimeout #(do (shortcut/refresh!) (refresh-shortcuts-list!)) 50)) + +(defn- show-undo-toast! + [description snapshot set-current-binding! self-id] + (shui/toast! + {:description description + :action (fn [{:keys [dismiss!]}] + [:button.font-medium.underline.cursor-pointer + {:on-click (fn [] + (execute-undo! snapshot) + ;; Update local state if dialog is still open + (when-let [own (some #(when (= (:action-id %) self-id) %) (:entries snapshot))] + (set-current-binding! (:previous-binding own))) + (dismiss!))} + "Undo"]) + :duration 6000} + :default)) + +(defn- conflict-action-names + "Extract human-readable action names from a key-conflicts map." + [key-conflicts] + (->> (for [[_g ks] key-conflicts + v (vals ks) + :let [conflicts-ids-map (second v)] + [id' _handler] conflicts-ids-map + :let [m (dh/shortcut-item id')] + :when m] + (dh/get-shortcut-desc m)) + (distinct) + (string/join ", "))) + (rum/defc ^:large-vars/cleanup-todo customize-shortcut-dialog-inner "user-binding: empty vector is for the unset state, nil is for the default binding" - [k action-name binding user-binding {:keys [saved-cb modal-id]}] + [k action-name binding user-binding {:keys [saved-cb close-fn]}] (let [*ref-el (rum/use-ref nil) - [modal-life _] (r/use-atom *customize-modal-life-sentry) [keystroke set-keystroke!] (rum/use-state "") [current-binding set-current-binding!] (rum/use-state (or user-binding binding)) [key-conflicts set-key-conflicts!] (rum/use-state nil) + [rec-state set-rec-state!] (rum/use-state :idle) + [accepted-info set-accepted-info!] (rum/use-state nil) + *auto-accept-timer (rum/use-ref nil) + *fade-timer (rum/use-ref nil) + ;; Refs to avoid stale closures in mount-only key handler effect + *rec-state-ref (rum/use-ref rec-state) + *keystroke-ref (rum/use-ref keystroke) + *current-binding-ref (rum/use-ref current-binding) + *key-conflicts-ref (rum/use-ref key-conflicts) handler-id (hooks/use-memo #(dh/get-group k) []) - dirty? (not= (or user-binding binding) current-binding) - keypressed? (not= "" keystroke) - save-keystroke-fn! + has-bindings? (boolean (seq (filter string? current-binding))) + + persist-binding! + (fn [new-binding] + (let [binding' (if (= binding new-binding) nil new-binding)] + (shortcut/persist-user-shortcut! k binding') + (js/setTimeout #(do (shortcut/refresh!) (saved-cb)) 50))) + + cancel-fn! (fn [] - ;; parse current binding conflicts - (if-let [current-conflicts (seq (dh/parse-conflicts-from-binding current-binding keystroke))] - (notification/show! - (str "Shortcut conflicts from existing binding: " - (pr-str (some->> current-conflicts (map #(shortcut-utils/decorate-binding %))))) - :error true :shortcut-conflicts/warning 5000) + (set-keystroke! "") + (set-key-conflicts! nil) + (set-rec-state! :idle)) - ;; get conflicts from the existed bindings map - (let [conflicts-map (dh/get-conflicts-by-keys keystroke handler-id)] - (if-not (seq conflicts-map) - (do (set-current-binding! (conj current-binding keystroke)) - (set-keystroke! "") - (set-key-conflicts! nil)) + reset-fn! + (fn [] + (let [undo-entries [{:action-id k :previous-binding current-binding}]] + (set-current-binding! binding) + (shortcut/persist-user-shortcut! k nil) + (js/setTimeout #(do (shortcut/refresh!) (saved-cb)) 50) + (show-undo-toast! "Reset to default" + {:entries undo-entries} + set-current-binding! k))) - ;; show conflicts - (set-key-conflicts! conflicts-map)))))] + override-fn! + (fn [] + (let [conflicts (rum/deref *key-conflicts-ref) + ks (rum/deref *keystroke-ref) + cur-binding (rum/deref *current-binding-ref)] + (when (and (seq conflicts) (not (string/blank? ks))) + ;; Build undo snapshot BEFORE mutations + (let [undo-entries + (into [{:action-id k :previous-binding cur-binding}] + (for [[_g kss] conflicts + v (vals kss) + :let [conflicts-ids-map (second v)] + [conflicting-id _handler] conflicts-ids-map] + {:action-id conflicting-id + :previous-binding (dh/shortcut-binding conflicting-id)})) + accepted-key ks + new-binding (conj cur-binding accepted-key)] - ;; TODO: back interaction for the shui dialog + ;; Remove binding from all conflicting actions + persist + (doseq [[_g kss] conflicts + v (vals kss) + :let [conflicts-ids-map (second v)] + [conflicting-id _handler] conflicts-ids-map] + (let [their-binding (dh/shortcut-binding conflicting-id) + filtered (vec (remove #(= % accepted-key) their-binding))] + (shortcut/persist-user-shortcut! conflicting-id + (if (empty? filtered) [] filtered)))) + + ;; Add to current binding + persist + (set-current-binding! new-binding) + (persist-binding! new-binding) + + ;; Undo toast + (show-undo-toast! + (str "Reassigned from " (conflict-action-names conflicts)) + {:entries undo-entries} + set-current-binding! k) + + ;; Transition to :accepted with reassign info + (set-accepted-info! {:key accepted-key :from (conflict-action-names conflicts)}) + (set-keystroke! "") + (set-key-conflicts! nil) + (set-rec-state! :accepted)))))] + + ;; Keep refs in sync for stale-closure safety + (rum/set-ref! *rec-state-ref rec-state) + (rum/set-ref! *keystroke-ref keystroke) + (rum/set-ref! *current-binding-ref current-binding) + (rum/set-ref! *key-conflicts-ref key-conflicts) + + ;; Auto-evaluate keystroke after 400ms debounce (hooks/use-effect! (fn [] - (let [mid (shui-dialog/get-first-modal-id) - mid' (shui-dialog/get-last-modal-id) - el (rum/deref *ref-el)] - (when (or (and (not mid') (= mid modal-id)) - (= mid' modal-id)) - (some-> el (.focus)) - (js/setTimeout - #(some-> (.querySelector el ".shortcut-record-control a.submit") - (.click)) 200)))) - [modal-life]) + (when-not (string/blank? keystroke) + (let [timer (js/setTimeout + (fn [] + (let [cur-binding (rum/deref *current-binding-ref)] + ;; Check same-action conflicts first + (if-let [_current-conflicts + (seq (dh/parse-conflicts-from-binding cur-binding keystroke))] + (do + (set-rec-state! :conflict-same) + (set-keystroke! "")) + ;; Check cross-action conflicts + (let [conflicts-map (dh/get-conflicts-by-keys keystroke handler-id {:exclude-ids #{k}})] + (if-not (seq conflicts-map) + ;; No same-context conflicts — check cross-context + (let [cross-conflicts (dh/get-cross-context-conflicts keystroke handler-id {:exclude-ids #{k}}) + accepted-key keystroke + new-binding (conj cur-binding accepted-key)] + ;; Always auto-save (cross-context conflicts are non-blocking) + (set-current-binding! new-binding) + (set-keystroke! "") + (set-key-conflicts! nil) + (persist-binding! new-binding) + (if (seq cross-conflicts) + ;; Amber warning — saved but informational + (set-accepted-info! {:key accepted-key + :from nil + :cross-context? true + :cross-action-name (conflict-action-names cross-conflicts) + :cross-context-label (dh/conflict-context-label cross-conflicts)}) + ;; Clean accept — no conflicts anywhere + (set-accepted-info! {:key accepted-key :from nil})) + (set-rec-state! :accepted)) + ;; Same-context conflicts — blocking red state + (do + (set-key-conflicts! conflicts-map) + (set-rec-state! :conflict-cross))))))) + 400)] + (rum/set-ref! *auto-accept-timer timer))) + #(when-let [timer (rum/deref *auto-accept-timer)] + (js/clearTimeout timer))) + [keystroke]) + ;; Auto-fade for transient states: conflict-same, esc-hint, accepted + (hooks/use-effect! + (fn [] + (when (#{:conflict-same :esc-hint :accepted} rec-state) + (let [ms (case rec-state + :esc-hint 2000 + :accepted (if (:cross-context? accepted-info) 6000 3000) + 3000) + timer (js/setTimeout + #(set-rec-state! :idle) + ms)] + (rum/set-ref! *fade-timer timer))) + #(when-let [timer (rum/deref *fade-timer)] + (js/clearTimeout timer))) + [rec-state]) + + ;; Key handler (mount-only, uses refs for current state) (hooks/use-effect! (fn [] (let [^js el (rum/deref *ref-el) @@ -261,94 +426,201 @@ (shortcut/listen-all!) (reset! *global-listener-setup? false)))] - ;; setup (events/listen key-handler "key" (fn [^js e] (.preventDefault e) - (set-key-conflicts! nil) - (set-keystroke! #(util/trim-safe (str % (shortcut/keyname e)))))) + (let [state (rum/deref *rec-state-ref) + key-code (.-keyCode e) + is-esc? (= key-code 27) + is-backspace? (= key-code 8) + is-cmd-enter? (and (= key-code 13) + (or (.-metaKey e) (.-ctrlKey e)))] + (cond + ;; Esc: never recordable, always cancel/dismiss + is-esc? + (do + ;; Always stop propagation so Esc doesn't close the Settings dialog + (.stopPropagation e) + (case state + :idle (close-fn) + :accepted (close-fn) + :esc-hint (close-fn) + :recording (do (set-keystroke! "") + (set-key-conflicts! nil) + (set-rec-state! :esc-hint)) + :conflict-cross (close-fn) + :conflict-same (close-fn) + nil)) + + ;; Backspace in conflict: remove pending keystroke + (and is-backspace? (#{:conflict-cross :conflict-same} state)) + (cancel-fn!) + + ;; Backspace in idle/accepted: remove last committed binding + (and is-backspace? + (#{:idle :accepted} state) + (string/blank? (rum/deref *keystroke-ref))) + (let [cur-binding (rum/deref *current-binding-ref)] + (when (seq (filter string? cur-binding)) + (let [new-binding (vec (butlast cur-binding)) + undo-entries [{:action-id k :previous-binding cur-binding}]] + (set-current-binding! new-binding) + (persist-binding! new-binding) + (set-rec-state! :idle) + (show-undo-toast! "Shortcut removed" + {:entries undo-entries} + set-current-binding! k)))) + + ;; Conflict-cross + Cmd+Enter => override + (and is-cmd-enter? (= state :conflict-cross)) + (override-fn!) + + ;; Conflict-cross + other keys => ignore (dead-end) + (= state :conflict-cross) + nil + + ;; Conflict-same / esc-hint + key => start new recording + (#{:conflict-same :esc-hint} state) + (when-let [kn (shortcut/keyname e)] + (set-rec-state! :recording) + (set-keystroke! (util/trim-safe kn))) + + ;; Idle / accepted + key => start recording + (#{:idle :accepted} state) + (when-let [kn (shortcut/keyname e)] + (set-rec-state! :recording) + (set-keystroke! (util/trim-safe kn))) + + ;; Recording + key => accumulate + (= state :recording) + (when-let [kn (shortcut/keyname e)] + (set-key-conflicts! nil) + (set-keystroke! #(util/trim-safe (str % kn)))))))) - ;; active (js/setTimeout #(.focus el) 128) - ;; teardown - #(do (some-> teardown-global! (apply nil)) - (.dispose key-handler) - (swap! *customize-modal-life-sentry inc)))) + #(do (when-let [timer (rum/deref *auto-accept-timer)] + (js/clearTimeout timer)) + (when-let [timer (rum/deref *fade-timer)] + (js/clearTimeout timer)) + (some-> teardown-global! (apply nil)) + (.dispose key-handler)))) []) - [:div.cp__shortcut-page-x-record-dialog-inner - {:class (util/classnames [{:keypressed keypressed? :dirty dirty?}]) - :tab-index -1 + ;; === V3 LAYOUT === + [:div.shortcut-popover + {:tab-index -1 :ref *ref-el} - [:div.sm:w-lsm - [:h1.text-2xl.pb-2 - (t :keymap/customize-for-label)] - [:p.mb-4.text-md [:b action-name]] + ;; TITLE + [:div.shortcut-popover-title action-name] - [:div.shortcuts-keys-wrap - [:span.flex.flex-wrap.mr-2.gap-2 - (for [x current-binding - :when (string? x)] - [:span.shortcut-binding-item.relative.select-none - {:key x} - (shui/shortcut x {:glow? false}) - [:a.shortcut-delete-x {:on-click (fn [] (set-current-binding! - (->> current-binding (remove #(= x %)) (into []))))} - (ui/icon "x" {:size 12})]])] + ;; INPUT FIELD + [:div.shortcut-input-field + {:class (when (#{:conflict-cross :conflict-same} rec-state) "conflict")} + ;; Existing bindings — each wrapped in a grouping container + (for [[idx x] (map-indexed vector current-binding) + :when (string? x)] + [:div.shortcut-input-binding {:key x} + (shui/shortcut x) + (when (#{:idle :accepted :esc-hint} rec-state) + [:a.shortcut-binding-remove + {:on-click (fn [^js e] + (.stopPropagation e) + (let [new-binding (vec (concat (subvec current-binding 0 idx) + (subvec current-binding (inc idx)))) + undo-entries [{:action-id k :previous-binding current-binding}]] + (set-current-binding! new-binding) + (persist-binding! new-binding) + (set-rec-state! :idle) + (show-undo-toast! "Shortcut removed" + {:entries undo-entries} + set-current-binding! k)))} + (ui/icon "x" {:size 12})])]) + ;; Recording in progress — dashed keys (uncommitted) + (when (and (#{:recording :conflict-cross :conflict-same} rec-state) + (not (string/blank? keystroke))) + [:div.shortcut-input-binding.shortcut-input-binding--pending + (shui/shortcut keystroke) + (when (#{:conflict-cross :conflict-same} rec-state) + [:a.shortcut-binding-remove + {:on-click (fn [^js e] + (.stopPropagation e) + (cancel-fn!))} + (ui/icon "x" {:size 12})])]) + ;; Placeholder + (when (#{:idle :recording :accepted} rec-state) + [:span.shortcut-input-placeholder "Press a shortcut\u2026"])] - ;; add shortcut - [:div.shortcut-record-control - ;; keypressed state - (if keypressed? - [:<> - (when-not (string/blank? keystroke) - (ui/render-keyboard-shortcut [keystroke])) + ;; FEEDBACK BANNER (conditional) + (case rec-state + :conflict-cross + [:div.shortcut-feedback.shortcut-feedback--error + [:span "Used by " + [:span.shortcut-feedback-name (str "\u201c" (conflict-action-names key-conflicts) "\u201d")]] + (ui/tooltip + (shui/button {:variant :destructive + :size :xs + :on-click override-fn!} + "Reassign") + "Remove from the other action and assign here")] - [:a.flex.items-center.active:opacity-90.submit - {:on-click save-keystroke-fn!} - (ui/icon "check" {:size 14})] - [:a.flex.items-center.text-red-600.hover:text-red-700.active:opacity-90.cancel - {:on-click (fn [] - (set-keystroke! "") - (set-key-conflicts! nil))} - (ui/icon "x" {:size 14})]] + :conflict-same + [:div.shortcut-feedback.shortcut-feedback--error + [:span "Already bound to this action"]] - [:code.flex.items-center - [:small.pr-1 (t :keymap/keystroke-record-setup-label)] (ui/icon "keyboard" {:size 14})])]]] + :accepted + (cond + (:cross-context? accepted-info) + [:div.shortcut-feedback.shortcut-feedback--warning + [:span "Also used for " + [:span.shortcut-feedback-name + (str "\u201c" (:cross-action-name accepted-info) "\u201d")] + (when-let [ctx (:cross-context-label accepted-info)] + (str " in " ctx))]] - ;; conflicts results - (when (seq key-conflicts) - (shortcut-conflicts-display k key-conflicts)) + (:from accepted-info) + [:div.shortcut-feedback.shortcut-feedback--success + [:span "Reassigned from " + [:span.shortcut-feedback-name (str "\u201c" (:from accepted-info) "\u201d")]]] - [:div.action-btns.text-right.mt-6.flex.justify-between.items-center - ;; restore default - (if (and (not= current-binding binding) (seq binding)) - [:a.flex.items-center.space-x-1.text-sm.fade-link - {:on-click #(set-current-binding! binding)} - (t :keymap/restore-to-default) - (for [b binding - :when (string? b)] - [:span.ml-1 {:key b} - (shui/shortcut b {:glow? false})])] - [:div]) + :else + [:div.shortcut-feedback.shortcut-feedback--success + [:span "Shortcut added"]]) - [:div.flex.flex-row.items-center.gap-2 - (ui/button - (t :save) - :disabled (not dirty?) - :on-click (fn [] - ;; TODO: check conflicts for the single same leader key - (let [binding' (if (nil? current-binding) [] current-binding) - conflicts (dh/get-conflicts-by-keys binding' handler-id {:exclude-ids #{k}})] - (if (seq conflicts) - (set-key-conflicts! conflicts) - (let [binding' (if (= binding binding') nil binding')] - (shortcut/persist-user-shortcut! k binding') - ;(notification/show! "Saved!" :success) - (shui/dialog-close!) - (saved-cb))))))]]])) + :esc-hint + [:div.shortcut-feedback.shortcut-feedback--muted + [:span "Esc is reserved"]] + + nil) + + ;; SEPARATOR + TOOLBAR + (shui/separator) + [:div.shortcut-toolbar + [:div.shortcut-toolbar-left + ;; Reset (only when changed from default) + (when (and (#{:idle :accepted} rec-state) + (not= current-binding binding)) + [:a.shortcut-toolbar-action + {:on-click reset-fn!} + (ui/icon "rotate" {:size 12}) + [:span "Reset"]])] + [:div.shortcut-toolbar-right + ;; Reassign hint (conflict-cross only) + (when (= :conflict-cross rec-state) + [:span.shortcut-toolbar-hint + "Reassign " + (shui/shortcut (if util/mac? "meta+enter" "ctrl+enter") {:style :compact})]) + ;; Remove hint (idle/accepted with bindings, or conflict states) + (when (or (and (#{:idle :accepted} rec-state) has-bindings?) + (#{:conflict-cross :conflict-same} rec-state)) + [:span.shortcut-toolbar-hint + "Remove " + (shui/shortcut "backspace" {:style :compact})]) + ;; Close/Cancel hint + [:span.shortcut-toolbar-hint + (if (= :recording rec-state) "Cancel " "Close ") + (shui/shortcut "escape" {:style :compact})]]]])) (defn build-categories-map [] @@ -357,7 +629,8 @@ (rum/defc ^:large-vars/cleanup-todo shortcut-keymap-x [] - (let [_ (r/use-atom shortcut-config/*category) + (let [[active-id] (r/use-atom *active-shortcut-id) + _ (r/use-atom shortcut-config/*category) _ (r/use-atom *refresh-sentry) [ready?, set-ready!] (rum/use-state false) [filters, set-filters!] (rum/use-state #{}) @@ -392,6 +665,14 @@ (js/setTimeout #(set-ready! true) 100)) []) + ;; Clean up any open shortcut popovers when this component unmounts + (hooks/use-effect! + (fn [] + (fn [] + (reset! *active-shortcut-id nil) + (shui/popup-hide-all!))) + []) + [:div.cp__shortcut-page-x [:header.relative [:h2.text-xs.opacity-70 @@ -455,14 +736,21 @@ (and (sequential? s) (sequential? keystroke') (apply = (map first [s keystroke']))))) binding'))))) - [:li.flex.items-center.justify-between.text-sm - {:key (str id)} + [:li.shortcut-row.flex.items-center.justify-between.text-sm + {:key (str id) + :class (when (= active-id id) "active") + :on-click (when (and id (not disabled?)) + (fn [^js e] + (if (= active-id id) + (let [popup-id (keyword (str "customize-shortcut-" (name id)))] + (reset! *active-shortcut-id nil) + (shui/popup-hide! popup-id)) + (let [anchor-el (-> (.-currentTarget e) (.querySelector ".action-wrap"))] + (open-customize-shortcut-dialog! anchor-el id)))))} [:span.label-wrap label] - [:a.action-wrap - {:class (util/classnames [{:disabled disabled?}]) - :on-click (when (and id (not disabled?)) - #(open-customize-shortcut-dialog! id))} + [:span.action-wrap + {:class (util/classnames [{:disabled disabled?}])} (cond (or unset? user-binding (false? user-binding)) diff --git a/src/main/frontend/components/shortcut.css b/src/main/frontend/components/shortcut.css index 4cbf4e91a7..823c7d4adc 100644 --- a/src/main/frontend/components/shortcut.css +++ b/src/main/frontend/components/shortcut.css @@ -79,6 +79,24 @@ li { @apply text-[15px] px-1; + &.shortcut-row { + @apply rounded-md cursor-pointer select-none py-1 px-2 -mx-1; + transition: background-color 100ms ease, color 100ms ease; + + &:hover { + background-color: var(--rx-gray-04, rgba(255, 255, 255, 0.06)); + } + + &:active { + @apply opacity-80; + } + + /* Active row (popover open) — stronger than hover so it stays visible */ + &.active { + background-color: var(--rx-gray-05, rgba(255, 255, 255, 0.1)); + } + } + &.th { @apply rounded mb-2 sticky top-0 cursor-pointer select-none active:opacity-80 px-2 py-1 z-[1]; @@ -92,56 +110,210 @@ .action-wrap { @apply flex space-x-2 items-center flex-nowrap - select-none active:opacity-70; + select-none; &.disabled { @apply opacity-60 cursor-default; } } } + + /* CSS-only hover dimming: dim all rows except hovered */ + &:hover li.shortcut-row:not(:hover):not(.active) { + color: var(--rx-gray-11); + } + + /* When popover is open: dim non-active rows, but restore on hover */ + &:has(.active) li.shortcut-row:not(.active) { + color: var(--rx-gray-11); + + &:hover { + color: var(--rx-gray-12); + } + } } } - &-record-dialog-inner { - @apply py-[28px] m-[-30px] px-[20px]; +} - h1 { - @apply relative top-[-8px]; +/* === V3 SHORTCUT POPOVER === */ +.shortcut-popover { + @apply flex flex-col; + width: 340px; + + &:focus, &:focus-within { outline: none; } + + > [data-orientation] { + @apply mt-0; + } +} + +.shortcut-popover-title { + @apply px-4 font-medium text-xs select-none; + padding-top: 16px; + padding-bottom: 2px; + color: var(--rx-gray-11, var(--ls-secondary-text-color)); +} + +/* Input field — borderless, content sits directly on popover surface */ +.shortcut-input-field { + @apply px-4 pt-2 pb-3 flex flex-wrap items-center; + column-gap: 12px; + row-gap: 6px; + min-height: 40px; +} + +.shortcut-input-placeholder { + @apply text-sm select-none; + color: var(--lx-gray-11, var(--rx-gray-11)); + opacity: 0.75; + background: linear-gradient( + 90deg, + currentColor 0%, + currentColor 40%, + var(--lx-gray-12, var(--rx-gray-12)) 50%, + currentColor 60%, + currentColor 100% + ); + background-size: 250% 100%; + background-position: 100% 0; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: shortcut-shimmer 4s ease-in-out infinite; + animation-delay: 1s; +} + +/* Each binding grouped in a subtle container */ +.shortcut-input-binding { + @apply inline-flex items-center flex-wrap rounded-md p-1; + background-color: var(--rx-gray-04-alpha, rgba(0, 0, 0, 0.07)); + max-width: 100%; + + .shortcut-binding-remove { + @apply flex items-center ml-1 cursor-pointer select-none; + color: var(--rx-gray-10); + + &:hover { + color: var(--rx-gray-12); } + } +} - &:active, &:focus, &:focus-within { - outline: burlywood hidden medium; +/* Allow long separate-key sequences to wrap inside the binding */ +.shortcut-input-binding .shui-shortcut-separate { + flex-wrap: wrap; + white-space: normal; +} + +/* Uncommitted binding: dashed key borders */ +.shortcut-input-binding--pending .shui-shortcut-key { + border-style: dashed; +} + +/* Conflict state: tint the binding container red, keys stay normal */ +.shortcut-input-field.conflict .shortcut-input-binding--pending { + background-color: var(--rx-red-06-alpha, rgb(239 68 68 / 0.2)); + + .shortcut-binding-remove { + color: var(--rx-red-11); + + &:hover { + color: var(--rx-red-12); } + } +} - .shortcuts-keys-wrap { - @apply flex items-center my-4 flex-wrap; +/* Keep combo overflow hidden in conflict */ +.shortcut-input-field.conflict .shortcut-input-binding--pending .shui-shortcut-combo { + overflow: hidden; +} - .shortcut-record-control { - @apply flex space-x-1 items-center select-none - rounded border-[2px] py-[2px] px-[2px]; - } +/* Feedback banner */ +.shortcut-feedback { + @apply flex items-center justify-between gap-2 px-4 py-2 text-xs; + animation: shortcut-fade-in 200ms ease-out; - .shortcut-binding-item { - a.shortcut-delete-x { - @apply hidden absolute right-[-8px] top-[-6px] h-[16px] w-[16px] - rounded-full bg-red-700 text-white leading-none items-center - justify-center cursor-pointer opacity-90 hover:opacity-100; - } + &--error { + color: var(--rx-red-11, #dc2626); + background-color: var(--rx-red-03-alpha, rgb(239 68 68 / 0.08)); + } + &--success { color: var(--rx-green-11, #16a34a); } + &--warning { + color: var(--rx-amber-11, #b45309); + background-color: var(--rx-amber-03-alpha, hsla(44, 100%, 50%, 0.1)); + } + &--muted { color: var(--rx-gray-09, var(--ls-secondary-text-color)); } +} - &:hover a.shortcut-delete-x { - @apply flex; - } - } - } +.shortcut-feedback-name { + @apply font-medium; + color: var(--rx-red-12, #991b1b); - &.keypressed { - .shortcut-record-control { - @apply pt-0 - } - } + .shortcut-feedback--success & { + color: var(--rx-green-12, #166534); + } - .action-btns { - } + .shortcut-feedback--warning & { + color: var(--rx-amber-12, #451a03); + } +} + +.shortcut-feedback-action { + @apply cursor-pointer font-medium whitespace-nowrap; + color: var(--rx-red-11, #dc2626); + &:hover { text-decoration: underline; } +} + +/* Toolbar */ +.shortcut-toolbar { + @apply flex items-center justify-between px-4 py-1.5 text-xs select-none; + color: var(--rx-gray-08, rgba(0, 0, 0, 0.4)); + margin-top: auto; +} + +.shortcut-toolbar-action { + @apply cursor-pointer flex items-center gap-1; + &:hover { color: var(--rx-gray-12, var(--ls-primary-text-color)); } +} + +.shortcut-toolbar-hint { + @apply ml-3; + color: var(--lx-gray-11, var(--rx-gray-11)); +} + +/* Animations */ +@keyframes shortcut-fade-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes shortcut-auto-fade { + 0% { opacity: 1; } + 70% { opacity: 1; } + 100% { opacity: 0.3; } +} + +@keyframes shortcut-shimmer { + 0%, 30% { background-position: 100% 0; } + 70%, 100% { background-position: -50% 0; } +} + +@media (prefers-reduced-motion: reduce) { + @keyframes shortcut-fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes shortcut-auto-fade { + from { opacity: 1; } + to { opacity: 0.3; } + } + + .shortcut-input-placeholder { + animation: none; + -webkit-text-fill-color: unset; + background: none; } } diff --git a/src/main/frontend/modules/shortcut/core.cljs b/src/main/frontend/modules/shortcut/core.cljs index ec162b4d4d..861a2275dc 100644 --- a/src/main/frontend/modules/shortcut/core.cljs +++ b/src/main/frontend/modules/shortcut/core.cljs @@ -276,12 +276,15 @@ meta (str "meta+") shift (str "shift+")))) -(defn keyname [e] - (let [name (get key-names (str (.-keyCode e)))] - (case name - nil nil - ("ctrl" "shift" "alt" "esc") nil - (str " " (name-with-meta e))))) +(defn keyname + ([e] (keyname e nil)) + ([e opts] + (let [name (get key-names (str (.-keyCode e)))] + (cond + (nil? name) nil + (#{"ctrl" "shift" "alt" "meta"} name) nil + (and (= name "esc") (not (:record-esc? opts))) nil + :else (str " " (name-with-meta e)))))) (defn persist-user-shortcut! [id binding] diff --git a/src/main/frontend/modules/shortcut/data_helper.cljs b/src/main/frontend/modules/shortcut/data_helper.cljs index 783bf6bcdb..f5215123c9 100644 --- a/src/main/frontend/modules/shortcut/data_helper.cljs +++ b/src/main/frontend/modules/shortcut/data_helper.cljs @@ -225,6 +225,71 @@ (remove #(empty? (vals (second %1)))) (into {}))))) +(def handler-display-labels + {:shortcut.handler/block-editing-only "editing mode" + :shortcut.handler/editor-global "editor" + :shortcut.handler/global-prevent-default "global" + :shortcut.handler/global-non-editing-only "navigation" + :shortcut.handler/misc "global" + :shortcut.handler/pdf "PDF viewer" + :shortcut.handler/auto-complete "autocomplete" + :shortcut.handler/cards "flashcards" + :shortcut.handler/date-picker "date picker"}) + +(defn get-cross-context-conflicts + "Like get-conflicts-by-keys but returns conflicts from OTHER handler contexts only. + Used for non-blocking amber warnings when a key is shared across contexts." + [ks handler-id {:keys [exclude-ids]}] + (let [global-handlers #{:shortcut.handler/editor-global + :shortcut.handler/global-non-editing-only + :shortcut.handler/global-prevent-default + :shortcut.handler/misc} + ks-bindings (get-bindings-keys-map) + caller-handlers (should-be-included-to-global-handler handler-id) + caller-is-global? (seq (set/intersection global-handlers caller-handlers))] + (->> (if (string? ks) [ks] ks) + (map (fn [k] + (when-let [k' (shortcut-utils/undecorate-binding k)] + (let [k-parsed (bean/->clj (shortcut-utils/safe-parse-string-binding k')) + + same-leading-key? + (fn [[k' _]] + (when (sequential? k-parsed) + (or (= k-parsed k') + (and (> (count k') (count k-parsed)) + (= (first k-parsed) (first k')))))) + + cross-context-ref + (fn [[k o]] + (when-let [{:keys [key refs]} o] + [k [key (reduce-kv + (fn [r id handler-id'] + (if (and (not (contains? exclude-ids id)) + (not (contains? caller-handlers handler-id')) + (not (and caller-is-global? + (contains? global-handlers handler-id')))) + (assoc r id handler-id') + r)) + {} refs)]]))] + + [k' (->> ks-bindings + (filterv same-leading-key?) + (mapv cross-context-ref) + (remove #(empty? (second (second %)))) + (into {}))])))) + (remove #(empty? (vals (second %)))) + (into {})))) + +(defn conflict-context-label + "Get the human-readable context label for the first conflict in a conflicts map." + [conflicts-map] + (->> (for [[_ ks] conflicts-map + v (vals ks) + :let [refs (second v)] + [_ handler-id'] refs] + (get handler-display-labels handler-id')) + (first))) + (defn parse-conflicts-from-binding [from-binding target] (when-let [from-binding (and (string? target)