Implement V3 shortcut customization popover with two-tier conflict detection

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 <noreply@anthropic.com>
This commit is contained in:
scheinriese
2026-03-08 20:49:03 +01:00
committed by Tienson Qin
parent 7f8c11680a
commit def3dfb233
5 changed files with 695 additions and 160 deletions

View File

@@ -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

View File

@@ -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))

View File

@@ -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;
}
}

View File

@@ -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]

View File

@@ -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)