From fac7e457bd9f314c7b74efe486cb4ac881ff1fad Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 25 May 2026 23:59:20 +0200 Subject: [PATCH] WIP --- backend/src/app/rpc/commands/profile.clj | 4 +- frontend/src/app/main/data/shortcuts.cljs | 77 ++++++- .../app/main/data/workspace/shortcuts.cljs | 22 +- .../data/workspace/shortcuts/customize.cljs | 42 ++++ frontend/src/app/main/refs.cljs | 3 + .../src/app/main/ui/workspace/main_menu.cljs | 29 ++- .../src/app/main/ui/workspace/palette.cljs | 17 +- .../app/main/ui/workspace/right_header.cljs | 19 +- .../main/ui/workspace/sidebar/shortcuts.cljs | 122 +++++++++-- .../main/ui/workspace/sidebar/shortcuts.scss | 36 ++++ .../sidebar/shortcuts/edit_modal.cljs | 190 ++++++++++++++++++ .../sidebar/shortcuts/edit_modal.scss | 125 ++++++++++++ .../app/main/ui/workspace/top_toolbar.cljs | 51 ++--- frontend/translations/en.po | 32 ++- 14 files changed, 702 insertions(+), 67 deletions(-) create mode 100644 frontend/src/app/main/data/workspace/shortcuts/customize.cljs create mode 100644 frontend/src/app/main/ui/workspace/sidebar/shortcuts/edit_modal.cljs create mode 100644 frontend/src/app/main/ui/workspace/sidebar/shortcuts/edit_modal.scss diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index ba1af4a362..03a1ce1c13 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -59,7 +59,9 @@ [:release-notes-viewed {:optional true} [::sm/text {:max 100}]] [:notifications {:optional true} schema:props-notifications] - [:workspace-visited {:optional true} ::sm/boolean]]) + [:workspace-visited {:optional true} ::sm/boolean] + [:custom-shortcuts {:optional true} + [:map-of {:gen/max 10} :keyword :string]]]) (def schema:profile [:map {:title "Profile"} diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 7d263da514..b0e670f065 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -121,6 +121,60 @@ [sc] (str/split sc #"\+| ")))) +(defn command->tooltip + "Converts a Mousetrap command string (e.g. \"command+shift+z\") to + a human-readable display string in the same format used by :tooltip + fields (e.g. \"⌘⇧Z\" on macOS or \"Ctrl+Shift+Z\" on Windows). + Returns nil for empty or unbound commands." + [command] + (when (and command (not= command "")) + (let [is-macos? (cf/check-platform? :macos) + parts (str/split command #"\+") + display-part (fn [p] + (case p + "ctrl" (if is-macos? mac-control "Ctrl+") + "command" (if is-macos? mac-command "Ctrl+") + "alt" (if is-macos? mac-option "Alt+") + "shift" (if is-macos? mac-shift "Shift+") + "up" up-arrow + "down" down-arrow + "left" left-arrow + "right" right-arrow + "del" (if is-macos? mac-delete "Del") + "backspace" (if is-macos? mac-delete "Backspace") + "escape" (if is-macos? mac-esc "Escape") + "enter" (if is-macos? mac-enter "Enter") + "space" "Space" + "tab" tab + (str/upper p)))] + (str/join "" (map display-part parts))))) + +(defn apply-custom-overrides + [shortcuts custom-overrides] + (if (empty? custom-overrides) + shortcuts + (reduce-kv + (fn [acc sc-key new-command] + (if (and (contains? acc sc-key) + (not (:disabled (get acc sc-key)))) + (-> acc + (assoc-in [sc-key :command] new-command) + (update sc-key dissoc :show-command)) + acc)) + shortcuts + custom-overrides))) + +(defn build-command-index + [shortcuts] + (reduce-kv + (fn [acc sc-key sc-def] + (let [commands (:command sc-def)] + (if (vector? commands) + (reduce #(assoc %1 %2 sc-key) acc commands) + (assoc acc commands sc-key)))) + {} + shortcuts)) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Events ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -180,8 +234,10 @@ (update state :shortcuts conj* [key shortcuts])) ptk/EffectEvent - (effect [_ _ _] - (reset! shortcuts))))) + (effect [_ state _] + (let [custom-overrides (get-in state [:profile :props :custom-shortcuts]) + effective (apply-custom-overrides shortcuts custom-overrides)] + (reset! effective)))))) (defn pop-shortcuts [key] @@ -193,5 +249,18 @@ ptk/EffectEvent (effect [_ state _] - (let [[_key shortcuts] (last (:shortcuts state))] - (reset! shortcuts))))) + (let [[_key shortcuts] (last (:shortcuts state)) + custom-overrides (get-in state [:profile :props :custom-shortcuts]) + effective (apply-custom-overrides shortcuts custom-overrides)] + (reset! effective))))) + +(defn rebind-shortcuts + [] + (ptk/reify ::rebind-shortcuts + ptk/EffectEvent + (effect [_ state _] + (let [[_key shortcuts] (last (:shortcuts state)) + custom-overrides (get-in state [:profile :props :custom-shortcuts]) + effective (apply-custom-overrides shortcuts custom-overrides)] + (when shortcuts + (reset! effective)))))) diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index a68934efc0..12c131ddaa 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -634,6 +634,24 @@ *assert* (merge debug-shortcuts))) -(defn get-tooltip [shortcut] +(defn get-tooltip + "Returns the tooltip string for a shortcut, using any custom binding + from the user's profile props if one exists, falling back to the + default :tooltip field." + [shortcut] (assert (contains? shortcuts shortcut) (str shortcut)) - (get-in shortcuts [shortcut :tooltip])) + (let [custom-shortcuts (get-in @st/state [:profile :props :custom-shortcuts]) + custom-command (get custom-shortcuts shortcut)] + (if (and custom-command (not= custom-command "")) + (ds/command->tooltip custom-command) + (get-in shortcuts [shortcut :tooltip])))) + +(defn get-effective-tooltip + "Returns the tooltip string for a shortcut given an already-resolved + custom-shortcuts map. Use this when you already have the custom + shortcuts derefed for the current render cycle." + [shortcut custom-shortcuts] + (let [custom-command (get custom-shortcuts shortcut)] + (if (and custom-command (not= custom-command "")) + (ds/command->tooltip custom-command) + (get-in shortcuts [shortcut :tooltip])))) diff --git a/frontend/src/app/main/data/workspace/shortcuts/customize.cljs b/frontend/src/app/main/data/workspace/shortcuts/customize.cljs new file mode 100644 index 0000000000..00d21e730b --- /dev/null +++ b/frontend/src/app/main/data/workspace/shortcuts/customize.cljs @@ -0,0 +1,42 @@ +;; 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.data.workspace.shortcuts.customize + (:require + [app.main.data.profile :as du] + [app.main.data.shortcuts :as ds] + [beicon.v2.core :as rx] + [potok.v2.core :as ptk])) + +(defn set-custom-shortcut + [shortcut-key new-command conflicting-key] + (ptk/reify ::set-custom-shortcut + ptk/WatchEvent + (watch [_ state _] + (let [current-customs (get-in state [:profile :props :custom-shortcuts] {}) + new-customs (-> current-customs + (assoc shortcut-key new-command) + (cond-> conflicting-key (assoc conflicting-key "")))] + (rx/of (du/update-profile-props {:custom-shortcuts new-customs}) + (ds/rebind-shortcuts)))))) + +(defn reset-custom-shortcut + [shortcut-key] + (ptk/reify ::reset-custom-shortcut + ptk/WatchEvent + (watch [_ state _] + (let [current-customs (get-in state [:profile :props :custom-shortcuts] {}) + new-customs (not-empty (dissoc current-customs shortcut-key))] + (rx/of (du/update-profile-props {:custom-shortcuts new-customs}) + (ds/rebind-shortcuts)))))) + +(defn reset-all-custom-shortcuts + [] + (ptk/reify ::reset-all-custom-shortcuts + ptk/WatchEvent + (watch [_ _ _] + (rx/of (du/update-profile-props {:custom-shortcuts nil}) + (ds/rebind-shortcuts))))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 771bbcd04f..be7396b910 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -33,6 +33,9 @@ (def profile (l/derived (l/key :profile) st/state)) +(def custom-shortcuts + (l/derived (fn [state] (get-in state [:profile :props :custom-shortcuts])) st/state)) + (def current-page-id (l/derived (l/key :current-page-id) st/state)) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 53d51a7970..83d3f88cfd 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -226,7 +226,7 @@ (mf/defc preferences-menu* {::mf/private true ::mf/wrap [mf/memo]} - [{:keys [layout profile toggle-flag on-close toggle-theme toggle-render]}] + [{:keys [layout profile toggle-flag on-close toggle-theme toggle-render show-shortcuts]}] (let [renderer (or (-> profile :props :renderer) :svg) show-nudge-options @@ -333,7 +333,18 @@ [:span {:class (stl/css :item-name)} (if (= renderer :wasm) (tr "workspace.header.menu.disable-webgl") - (tr "workspace.header.menu.enable-webgl"))]])])) + (tr "workspace.header.menu.enable-webgl"))]]) + + [:> dropdown-menu-item* {:on-click show-shortcuts + :class (stl/css :base-menu-item :submenu-item) + :on-key-down (fn [event] + (when (kbd/enter? event) + (show-shortcuts event))) + :data-testid "change-shortcuts" + :id "file-menu-change-shortcuts"} + [:span {:class (stl/css :item-name)} + (tr "workspace.header.menu.change-shortcuts")] + [:> shortcuts* {:id :show-shortcuts}]]])) (mf/defc view-menu* {::mf/private true @@ -967,6 +978,19 @@ ::ev/origin "workspace:menu"}) (modal/show :plugin-management {})))) + show-shortcuts + (mf/use-fn + (mf/deps layout) + (fn [event] + (dom/stop-propagation event) + (reset! show-menu* false) + (reset! selected-sub-menu* nil) + (when (contains? layout :collapse-left-sidebar) + (st/emit! (dw/toggle-layout-flag :collapse-left-sidebar))) + (st/emit! + (-> (dw/toggle-layout-flag :shortcuts) + (vary-meta assoc ::ev/origin "workspace-menu"))))) + subscription (:subscription (:props profile)) subscription-type (get-subscription-type subscription)] @@ -1133,6 +1157,7 @@ :toggle-flag toggle-flag :toggle-theme toggle-theme :toggle-render toggle-render + :show-shortcuts show-shortcuts :on-close close-sub-menu}] :plugins diff --git a/frontend/src/app/main/ui/workspace/palette.cljs b/frontend/src/app/main/ui/workspace/palette.cljs index 11ba417e9b..7b5d5250cc 100644 --- a/frontend/src/app/main/ui/workspace/palette.cljs +++ b/frontend/src/app/main/ui/workspace/palette.cljs @@ -51,9 +51,12 @@ (mf/defc palette* [{:keys [layout on-change-size]}] - (let [color-palette? (:colorpalette layout) - text-palette? (:textpalette layout) - hide-palettes? (:hide-palettes layout) + (let [color-palette? (:colorpalette layout) + text-palette? (:textpalette layout) + hide-palettes? (:hide-palettes layout) + + custom-shortcuts (mf/deref refs/custom-shortcuts) + get-tt #(sc/get-effective-tooltip % custom-shortcuts) read-only? (mf/use-ctx ctx/workspace-read-only?) container (mf/use-ref nil) @@ -179,16 +182,16 @@ [:ul {:class (dm/str size-classname " " (stl/css-case :palette-btn-list true :hidden-bts hide-palettes?))} [:li {:class (stl/css :palette-item)} - [:button {:title (tr "workspace.toolbar.color-palette" (sc/get-tooltip :toggle-colorpalette)) - :aria-label (tr "workspace.toolbar.color-palette" (sc/get-tooltip :toggle-colorpalette)) + [:button {:title (tr "workspace.toolbar.color-palette" (get-tt :toggle-colorpalette)) + :aria-label (tr "workspace.toolbar.color-palette" (get-tt :toggle-colorpalette)) :class (stl/css-case :palette-btn true :selected color-palette?) :on-click on-select-color-palette} deprecated-icon/drop-icon]] [:li {:class (stl/css :palette-item)} - [:button {:title (tr "workspace.toolbar.text-palette" (sc/get-tooltip :toggle-textpalette)) - :aria-label (tr "workspace.toolbar.text-palette" (sc/get-tooltip :toggle-textpalette)) + [:button {:title (tr "workspace.toolbar.text-palette" (get-tt :toggle-textpalette)) + :aria-label (tr "workspace.toolbar.text-palette" (get-tt :toggle-textpalette)) :class (stl/css-case :palette-btn true :selected text-palette?) :on-click on-select-text-palette} diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index 947113c5f9..ff5e6a2152 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -40,8 +40,10 @@ {::mf/wrap [mf/memo] ::mf/wrap-props false} [{:keys [zoom on-increase on-decrease on-zoom-reset on-zoom-fit on-zoom-selected]}] - (let [open* (mf/use-state false) - open? (deref open*) + (let [open* (mf/use-state false) + open? (deref open*) + custom-shortcuts (mf/deref refs/custom-shortcuts) + get-tt #(sc/get-effective-tooltip % custom-shortcuts) open-dropdown (mf/use-fn @@ -97,14 +99,14 @@ :on-click on-zoom-fit} (tr "workspace.header.zoom-fit-all") [:span {:class (stl/css :shortcuts)} - (for [sc (scd/split-sc (sc/get-tooltip :fit-all))] + (for [sc (scd/split-sc (get-tt :fit-all))] [:span {:class (stl/css :shortcut-key) :key (str "zoom-fit-" sc)} sc])]] [:li {:class (stl/css :zoom-option) :on-click on-zoom-selected} (tr "workspace.header.zoom-selected") [:span {:class (stl/css :shortcuts)} - (for [sc (scd/split-sc (sc/get-tooltip :zoom-selected))] + (for [sc (scd/split-sc (get-tt :zoom-selected))] [:span {:class (stl/css :shortcut-key) :key (str "zoom-selected-" sc)} sc])]]]]])) @@ -118,6 +120,9 @@ read-only? (mf/use-ctx ctx/workspace-read-only?) selected-drawtool (mf/deref refs/selected-drawing-tool) + custom-shortcuts (mf/deref refs/custom-shortcuts) + get-tt #(sc/get-effective-tooltip % custom-shortcuts) + on-increase (mf/use-fn #(st/emit! (dw/increase-zoom nil))) on-decrease (mf/use-fn #(st/emit! (dw/decrease-zoom nil))) on-zoom-reset (mf/use-fn #(st/emit! dw/reset-zoom)) @@ -211,8 +216,8 @@ :on-zoom-selected on-zoom-selected}]] [:div {:class (stl/css :comments-section)} - [:button {:title (tr "workspace.toolbar.comments" (sc/get-tooltip :add-comment)) - :aria-label (tr "workspace.toolbar.comments" (sc/get-tooltip :add-comment)) + [:button {:title (tr "workspace.toolbar.comments" (get-tt :add-comment)) + :aria-label (tr "workspace.toolbar.comments" (get-tt :add-comment)) :class (stl/css-case :comments-btn true :selected (= selected-drawtool :comments)) :on-click toggle-comments @@ -239,7 +244,7 @@ deprecated-icon/share]) [:a {:class (stl/css :viewer-btn) - :title (tr "workspace.header.viewer" (sc/get-tooltip :open-viewer)) + :title (tr "workspace.header.viewer" (get-tt :open-viewer)) :on-click nav-to-viewer} deprecated-icon/play]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs index aecf386f6a..49e7f1ad5e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.cljs @@ -11,15 +11,20 @@ [app.common.data.macros :as dm] [app.config :as cf] [app.main.data.dashboard.shortcuts] + [app.main.data.modal :as modal] [app.main.data.shortcuts :as ds] [app.main.data.viewer.shortcuts] [app.main.data.workspace :as dw] [app.main.data.workspace.path.shortcuts] [app.main.data.workspace.shortcuts] + [app.main.data.workspace.shortcuts.customize :as customize] + [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.search-bar :refer [search-bar*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.product.panel-title :refer [panel-title*]] + [app.main.ui.workspace.sidebar.shortcuts.edit-modal] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.strings :refer [matches-search]] @@ -262,7 +267,8 @@ :command command}])])])) (mf/defc shortcut-row* - [{:keys [elements filter-term is-match-section is-match-subsection]}] + [{:keys [elements filter-term is-match-section is-match-subsection + editable? custom-shortcuts on-edit on-reset]}] (let [shortcut-name (keys elements) shortcut-translations (map #(translation-keyname :sc %) shortcut-name) match-shortcut? (some #(matches-search % @filter-term) shortcut-translations) @@ -275,13 +281,33 @@ (for [command-translate sorted-filtered] (let [sc-by-translate (first (filter #(= (:translation (second %)) command-translate) elements)) [command comand-info] sc-by-translate - content (or (:show-command comand-info) (:command comand-info))] - [:li {:class (stl/css :shortcuts-name) + content (or (:show-command comand-info) (:command comand-info)) + customized? (and editable? (contains? custom-shortcuts command))] + [:li {:class (stl/css-case :shortcuts-name true + :customized customized?) :key command-translate} [:span {:class (stl/css :command-name)} command-translate] - [:> shortcuts-keys* {:content content - :command command}]]))])) + [:div {:class (stl/css :shortcut-actions)} + [:> shortcuts-keys* {:content content + :command command}] + (when editable? + [:div {:class (stl/css :edit-buttons)} + (when customized? + [:> icon-button* {:variant "ghost" + :aria-label (tr "shortcuts.reset") + :on-click (fn [e] + (dom/stop-propagation e) + (on-reset command)) + :icon i/reload + :icon-size "s"}]) + [:> icon-button* {:variant "ghost" + :aria-label (tr "shortcuts.edit") + :on-click (fn [e] + (dom/stop-propagation e) + (on-edit command)) + :icon i/curve + :icon-size "s"}]])]]))])) (mf/defc section-title* [{:keys [name is-visible is-sub]}] @@ -295,7 +321,8 @@ (stl/css :section-name))} name]]) (mf/defc shortcut-subsection* - [{:keys [subsections manage-sections filter-term is-match-section open-sections]}] + [{:keys [subsections manage-sections filter-term is-match-section open-sections + editable? custom-shortcuts on-edit on-reset]}] (let [subsections-names (keys subsections) subsection-translations (if (= :none (first subsections-names)) (map #(translation-keyname :sc %) subsections-names) @@ -307,7 +334,11 @@ [:> shortcut-row* {:elements (:children basic-shortcuts) :filter-term filter-term :is-match-section is-match-section - :is-match-subsection true}]) + :is-match-subsection true + :editable? editable? + :custom-shortcuts custom-shortcuts + :on-edit on-edit + :on-reset on-reset}]) [:ul {:class (stl/css :subsection-menu)} (for [sub-translated sorted-translations] @@ -328,10 +359,15 @@ [:> shortcut-row* {:elements (:children sub-info) :filter-term filter-term :is-match-section is-match-section - :is-match-subsection match-subsection?}]]])))]))) + :is-match-subsection match-subsection? + :editable? editable? + :custom-shortcuts custom-shortcuts + :on-edit on-edit + :on-reset on-reset}]]])))]))) (mf/defc shortcut-section* - [{:keys [section manage-sections open-sections filter-term]}] + [{:keys [section manage-sections open-sections filter-term + editable? custom-shortcuts on-edit on-reset]}] (let [[section-key section-info] section section-id (:id section-info) section-translation (translation-keyname :sec section-key) @@ -359,13 +395,24 @@ :open-sections open-sections :manage-sections manage-sections :is-match-section match-section? - :filter-term filter-term}]]]))) + :filter-term filter-term + :editable? editable? + :custom-shortcuts custom-shortcuts + :on-edit on-edit + :on-reset on-reset}]]]))) + +(def ^:private workspace-shortcuts-raw + (d/deep-merge app.main.data.workspace.path.shortcuts/shortcuts + app.main.data.workspace.shortcuts/shortcuts)) (mf/defc shortcuts-container* [{:keys [class]}] - (let [workspace-shortcuts app.main.data.workspace.shortcuts/shortcuts - path-shortcuts app.main.data.workspace.path.shortcuts/shortcuts - all-workspace-shortcuts (->> (d/deep-merge path-shortcuts workspace-shortcuts) + (let [profile (mf/deref refs/profile) + custom-shortcuts (get-in profile [:props :custom-shortcuts]) + + workspace-shortcuts-custom (ds/apply-custom-overrides workspace-shortcuts-raw custom-shortcuts) + + all-workspace-shortcuts (->> workspace-shortcuts-custom (add-translation :sc) (into {})) @@ -484,7 +531,30 @@ (mf/use-callback (fn [_] (reset! open-sections [[1]]) - (reset! filter-term "")))] + (reset! filter-term ""))) + + on-edit-shortcut + (mf/use-callback + (mf/deps custom-shortcuts workspace-shortcuts-custom) + (fn [shortcut-key] + (let [default-command (:command (get workspace-shortcuts-raw shortcut-key)) + current-command (or (get custom-shortcuts shortcut-key) default-command)] + (st/emit! (modal/show :shortcut-edit + {:shortcut-key shortcut-key + :shortcut-name (translation-keyname :sc shortcut-key) + :current-command current-command + :default-command default-command + :all-shortcuts workspace-shortcuts-custom}))))) + + on-reset-shortcut + (mf/use-callback + (fn [shortcut-key] + (st/emit! (customize/reset-custom-shortcut shortcut-key)))) + + on-reset-all + (mf/use-callback + (fn [_] + (st/emit! (customize/reset-all-custom-shortcuts))))] [:div {:class (dm/str class " " (stl/css :shortcuts))} [:> panel-title* {:class (stl/css :shortcuts-title) @@ -497,14 +567,26 @@ :value @filter-term :placeholder (tr "shortcuts.title") :icon-id i/search - :auto-focus true}]] + :auto-focus true}] + (when (seq custom-shortcuts) + [:> icon-button* {:variant "ghost" + :aria-label (tr "shortcuts.reset-all") + :on-click on-reset-all + :icon i/reload + :icon-size "s"}])] (if match-any? [:div {:class (stl/css :shortcuts-list)} (for [section all-shortcuts] - [:> shortcut-section* {:key (->> section second :id first) - :section section - :manage-sections manage-sections - :open-sections open-sections - :filter-term filter-term}])] + (let [[section-key _] section + ws-editable? (contains? #{:basics :workspace} section-key)] + [:> shortcut-section* {:key (->> section second :id first) + :section section + :manage-sections manage-sections + :open-sections open-sections + :filter-term filter-term + :editable? ws-editable? + :custom-shortcuts custom-shortcuts + :on-edit on-edit-shortcut + :on-reset on-reset-shortcut}]))] [:div {:class (stl/css :not-found)} (tr "shortcuts.not-found")])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss index d375082b1a..a99f6ce668 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss @@ -16,7 +16,16 @@ } .search-field { + display: flex; + align-items: center; + gap: deprecated.$s-4; margin: deprecated.$s-16 deprecated.$s-12 deprecated.$s-4 deprecated.$s-12; + + // The search bar should fill available space; the reset-all button sits at the end + & > :first-child { + flex: 1; + min-width: 0; + } } .shortcuts-title { @@ -91,6 +100,33 @@ margin-left: deprecated.$s-2; color: var(--pill-foreground-color); + flex: 1; + min-width: 0; + } + + .shortcut-actions { + display: flex; + align-items: center; + gap: deprecated.$s-2; + } + + .edit-buttons { + display: flex; + align-items: center; + opacity: 0; + transition: opacity 0.15s ease; + } + + &:hover .edit-buttons { + opacity: 1; + } + + &.customized { + background-color: var(--pill-background-color-hover, var(--pill-background-color)); + + .edit-buttons { + opacity: 1; + } } .keys { diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts/edit_modal.cljs b/frontend/src/app/main/ui/workspace/sidebar/shortcuts/edit_modal.cljs new file mode 100644 index 0000000000..f3dc350755 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts/edit_modal.cljs @@ -0,0 +1,190 @@ +;; 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.sidebar.shortcuts.edit-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.config :as cf] + [app.main.data.modal :as modal] + [app.main.data.shortcuts :as ds] + [app.main.data.workspace.shortcuts.customize :as customize] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.notifications.context-notification :refer [context-notification*]] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [cuerdas.core :as str] + [goog.events :as events] + [rumext.v2 :as mf]) + (:import + goog.events.EventType)) + +(def ^:private modifier-keys + #{"Control" "Shift" "Alt" "Meta"}) + +(def ^:private key-name-map + {"ArrowUp" "up" + "ArrowDown" "down" + "ArrowLeft" "left" + "ArrowRight" "right" + "Escape" "escape" + "Enter" "enter" + "Backspace" "backspace" + "Delete" "del" + "Tab" "tab" + " " "space"}) + +(defn- shortcut-translation + [keyname] + (tr (str "shortcuts." (d/name keyname)))) + +(defn- keyboard-event->mousetrap + [^js event] + (let [parts (cond-> [] + (and (.-ctrlKey event) + (not (cf/check-platform? :macos))) + (conj "ctrl") + + (and (.-metaKey event) + (cf/check-platform? :macos)) + (conj "command") + + (.-altKey event) + (conj "alt") + + (.-shiftKey event) + (conj "shift")) + key (.-key event) + key (if (contains? modifier-keys key) + nil + (or (get key-name-map key) + (.toLowerCase key)))] + (when key + (str/join "+" (conj parts key))))) + +(defn- keyboard-event->display-parts + [^js event] + (let [parts (cond-> [] + (and (.-ctrlKey event) + (not (cf/check-platform? :macos))) + (conj "ctrl") + + (and (.-metaKey event) + (cf/check-platform? :macos)) + (conj "command") + + (.-altKey event) + (conj "alt") + + (.-shiftKey event) + (conj "shift")) + key (.-key event)] + (if (contains? modifier-keys key) + {:modifiers parts :finalized? false} + {:modifiers parts + :final-key (or (get key-name-map key) (.toLowerCase key)) + :finalized? true}))) + +(defn- find-conflict + [new-command all-shortcuts current-key] + (let [command-index (ds/build-command-index all-shortcuts)] + (when-let [conflicting-key (get command-index new-command)] + (when (not= conflicting-key current-key) + {:key conflicting-key + :name (shortcut-translation conflicting-key)})))) + +(mf/defc shortcut-edit-dialog + {::mf/register modal/components + ::mf/register-as :shortcut-edit} + [{:keys [shortcut-key shortcut-name current-command default-command all-shortcuts]}] + (let [recorded-command (mf/use-state nil) + display-parts (mf/use-state nil) + conflict (mf/use-state nil) + + has-recording? (some? @recorded-command) + + cancel-fn + (mf/use-callback + (fn [event] + (dom/prevent-default event) + (st/emit! (modal/hide)))) + + save-fn + (mf/use-callback + (mf/deps @recorded-command @conflict shortcut-key) + (fn [event] + (dom/prevent-default event) + (when @recorded-command + (st/emit! (modal/hide) + (customize/set-custom-shortcut + shortcut-key + @recorded-command + (:key @conflict))))))] + + (mf/with-effect [] + (letfn [(on-keydown [^js event] + (.preventDefault event) + (.stopPropagation event) + (let [command (keyboard-event->mousetrap event) + parts (keyboard-event->display-parts event)] + (reset! display-parts parts) + (when command + (reset! recorded-command command) + (reset! conflict (find-conflict command all-shortcuts shortcut-key)))))] + (->> (events/listen js/document EventType.KEYDOWN on-keydown) + (partial events/unlistenByKey)))) + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-title)} (tr "shortcuts.edit-modal.title")] + [:div {:class (stl/css :modal-close-btn)} + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click cancel-fn + :icon i/close}]]] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :shortcut-info)} + [:span {:class (stl/css :shortcut-label)} shortcut-name] + [:div {:class (stl/css :current-keys)} + [:span {:class (stl/css :current-label)} (tr "shortcuts.edit-modal.current")] + [:span {:class (stl/css :current-value)} + (if (vector? current-command) + (str/join " / " current-command) + current-command)]]] + + [:div {:class (stl/css :recording-area)} + (if (nil? @display-parts) + [:span {:class (stl/css :recording-hint)} + (tr "shortcuts.edit-modal.press-keys")] + + [:div {:class (stl/css :recorded-keys)} + (for [mod (:modifiers @display-parts)] + [:span {:class (stl/css :key) :key mod} mod]) + (when (:final-key @display-parts) + [:span {:class (stl/css :key)} (:final-key @display-parts)]) + (when-not (:finalized? @display-parts) + [:span {:class (stl/css :recording-ellipsis)} + (tr "shortcuts.edit-modal.recording")])])] + + (when @conflict + [:> context-notification* {:level :warning + :appearance :ghost} + (tr "shortcuts.edit-modal.conflict" (:name @conflict))])] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + [:> button* {:variant "secondary" + :on-click cancel-fn} + (tr "shortcuts.edit-modal.cancel")] + [:> button* {:variant "primary" + :disabled (not has-recording?) + :on-click save-fn} + (tr "shortcuts.edit-modal.save")]]]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts/edit_modal.scss b/frontend/src/app/main/ui/workspace/sidebar/shortcuts/edit_modal.scss new file mode 100644 index 0000000000..c7ced3dfde --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts/edit_modal.scss @@ -0,0 +1,125 @@ +// 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 + +@use "refactor/common-refactor.scss" as deprecated; + +.modal-overlay { + @extend %modal-overlay-base; +} + +.modal-container { + @extend %modal-container-base; + + display: flex; + flex-direction: column; + gap: var(--sp-xl); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-title { + @include deprecated.headline-medium-typography; + + color: var(--modal-title-foreground-color); +} + +.modal-close-btn { + position: absolute; + top: var(--sp-m); + right: var(--sp-m); +} + +.modal-content { + display: flex; + flex-direction: column; + gap: var(--sp-l); +} + +.shortcut-info { + display: flex; + flex-direction: column; + gap: var(--sp-s); +} + +.shortcut-label { + @include deprecated.body-large-typography; + + color: var(--modal-title-foreground-color); + font-weight: bold; +} + +.current-keys { + display: flex; + align-items: center; + gap: var(--sp-s); +} + +.current-label { + @include deprecated.body-small-typography; + + color: var(--color-foreground-secondary); +} + +.current-value { + @include deprecated.body-small-typography; + + color: var(--color-foreground-secondary); + text-transform: capitalize; +} + +.recording-area { + @include deprecated.flex-center; + + min-height: deprecated.$s-60; + padding: var(--sp-l); + border-radius: deprecated.$br-8; + border: 2px dashed var(--color-foreground-secondary); + background-color: var(--pill-background-color); +} + +.recording-hint { + @include deprecated.body-large-typography; + + color: var(--color-foreground-secondary); +} + +.recorded-keys { + @include deprecated.flex-center; + + gap: deprecated.$s-4; +} + +.key { + @include deprecated.body-small-typography; + @include deprecated.flex-center; + + text-transform: capitalize; + height: deprecated.$s-28; + padding: deprecated.$s-4 deprecated.$s-8; + border-radius: deprecated.$s-6; + background-color: var(--menu-shortcut-background-color); + color: var(--pill-foreground-color); +} + +.recording-ellipsis { + @include deprecated.body-small-typography; + + color: var(--color-foreground-secondary); + margin-left: var(--sp-s); +} + +.modal-footer { + display: flex; + justify-content: flex-end; +} + +.action-buttons { + @extend %modal-action-btns; +} diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index eb9675beae..563f13090c 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -87,8 +87,10 @@ (mf/defc image-upload* {::mf/wrap [mf/memo]} [] - (let [ref (mf/use-ref nil) - file-id (mf/use-ctx ctx/current-file-id) + (let [ref (mf/use-ref nil) + file-id (mf/use-ctx ctx/current-file-id) + custom-shortcuts (mf/deref refs/custom-shortcuts) + get-tt #(sc/get-effective-tooltip % custom-shortcuts) on-click (mf/use-fn @@ -111,8 +113,8 @@ (st/emit! (dwm/upload-media-workspace params)))))] [:li [:button - {:title (tr "workspace.toolbar.image" (sc/get-tooltip :insert-image)) - :aria-label (tr "workspace.toolbar.image" (sc/get-tooltip :insert-image)) + {:title (tr "workspace.toolbar.image" (get-tt :insert-image)) + :aria-label (tr "workspace.toolbar.image" (get-tt :insert-image)) :on-click on-click :class (stl/css :main-toolbar-options-button)} deprecated-icon/img @@ -142,8 +144,9 @@ (let [drawtool (mf/deref refs/selected-drawing-tool) edition (mf/deref refs/selected-edition) - profile (mf/deref refs/profile) - props (get profile :props) + profile (mf/deref refs/profile) + props (get profile :props) + custom-shortcuts (mf/deref refs/custom-shortcuts) read-only? (mf/use-ctx ctx/workspace-read-only?) rulers? (mf/deref refs/rulers?) @@ -181,10 +184,12 @@ (dom/blur! (dom/get-target event)) (st/emit! (dwc/toggle-toolbar-visibility)))) + get-tt #(sc/get-effective-tooltip % custom-shortcuts) + test-tooltip-board-text (if (not (:workspace-visited props)) - (tr "workspace.toolbar.frame-first-time" (sc/get-tooltip :draw-frame)) - (tr "workspace.toolbar.frame" (sc/get-tooltip :draw-frame)))] + (tr "workspace.toolbar.frame-first-time" (get-tt :draw-frame)) + (tr "workspace.toolbar.frame" (get-tt :draw-frame)))] (when-not ^boolean read-only? [:aside {:class (stl/css-case :main-toolbar true @@ -194,8 +199,8 @@ :data-testid "toolbar-options"} [:li [:button - {:title (tr "workspace.toolbar.move" (sc/get-tooltip :move)) - :aria-label (tr "workspace.toolbar.move" (sc/get-tooltip :move)) + {:title (tr "workspace.toolbar.move" (get-tt :move)) + :aria-label (tr "workspace.toolbar.move" (get-tt :move)) :class (stl/css-case :main-toolbar-options-button true :selected (and (nil? drawtool) (not edition))) @@ -205,7 +210,7 @@ [:li [:button {:title test-tooltip-board-text - :aria-label (tr "workspace.toolbar.frame" (sc/get-tooltip :draw-frame)) + :aria-label (tr "workspace.toolbar.frame" (get-tt :draw-frame)) :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :frame)) :on-click select-drawtool :data-tool "frame" @@ -213,8 +218,8 @@ deprecated-icon/board]] [:li [:button - {:title (tr "workspace.toolbar.rect" (sc/get-tooltip :draw-rect)) - :aria-label (tr "workspace.toolbar.rect" (sc/get-tooltip :draw-rect)) + {:title (tr "workspace.toolbar.rect" (get-tt :draw-rect)) + :aria-label (tr "workspace.toolbar.rect" (get-tt :draw-rect)) :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :rect)) :on-click select-drawtool :data-tool "rect" @@ -222,8 +227,8 @@ deprecated-icon/rectangle]] [:li [:button - {:title (tr "workspace.toolbar.ellipse" (sc/get-tooltip :draw-ellipse)) - :aria-label (tr "workspace.toolbar.ellipse" (sc/get-tooltip :draw-ellipse)) + {:title (tr "workspace.toolbar.ellipse" (get-tt :draw-ellipse)) + :aria-label (tr "workspace.toolbar.ellipse" (get-tt :draw-ellipse)) :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :circle)) :on-click select-drawtool :data-tool "circle" @@ -231,8 +236,8 @@ deprecated-icon/ellipse]] [:li [:button - {:title (tr "workspace.toolbar.text" (sc/get-tooltip :draw-text)) - :aria-label (tr "workspace.toolbar.text" (sc/get-tooltip :draw-text)) + {:title (tr "workspace.toolbar.text" (get-tt :draw-text)) + :aria-label (tr "workspace.toolbar.text" (get-tt :draw-text)) :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :text)) :on-click select-drawtool :data-tool "text"} @@ -242,8 +247,8 @@ [:li [:button - {:title (tr "workspace.toolbar.curve" (sc/get-tooltip :draw-curve)) - :aria-label (tr "workspace.toolbar.curve" (sc/get-tooltip :draw-curve)) + {:title (tr "workspace.toolbar.curve" (get-tt :draw-curve)) + :aria-label (tr "workspace.toolbar.curve" (get-tt :draw-curve)) :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :curve)) :on-click select-drawtool :data-tool "curve" @@ -251,8 +256,8 @@ deprecated-icon/curve]] [:li [:button - {:title (tr "workspace.toolbar.path" (sc/get-tooltip :draw-path)) - :aria-label (tr "workspace.toolbar.path" (sc/get-tooltip :draw-path)) + {:title (tr "workspace.toolbar.path" (get-tt :draw-path)) + :aria-label (tr "workspace.toolbar.path" (get-tt :draw-path)) :class (stl/css-case :main-toolbar-options-button true :selected (= drawtool :path)) :on-click select-drawtool :data-tool "path" @@ -262,8 +267,8 @@ (when (features/active-feature? @st/state "plugins/runtime") [:li [:button - {:title (tr "workspace.toolbar.plugins" (sc/get-tooltip :plugins)) - :aria-label (tr "workspace.toolbar.plugins" (sc/get-tooltip :plugins)) + {:title (tr "workspace.toolbar.plugins" (get-tt :plugins)) + :aria-label (tr "workspace.toolbar.plugins" (get-tt :plugins)) :class (stl/css :main-toolbar-options-button) :on-click #(st/emit! (ev/event {::ev/name "open-plugins-manager" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index a3559f9c1d..f434dacf30 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -5565,7 +5565,37 @@ msgstr "Set thumbnails" msgid "shortcuts.title" msgstr "Keyboard shortcuts" -#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:185 +msgid "shortcuts.edit" +msgstr "Edit shortcut" + +msgid "shortcuts.reset" +msgstr "Reset to default" + +msgid "shortcuts.reset-all" +msgstr "Reset all shortcuts" + +msgid "shortcuts.edit-modal.title" +msgstr "Edit shortcut" + +msgid "shortcuts.edit-modal.current" +msgstr "Current shortcut" + +msgid "shortcuts.edit-modal.press-keys" +msgstr "Press the desired key combination" + +msgid "shortcuts.edit-modal.recording" +msgstr "Recording..." + +msgid "shortcuts.edit-modal.conflict" +msgstr "This shortcut is already assigned to \"%s\". The old shortcut will be unbound." + +msgid "shortcuts.edit-modal.save" +msgstr "Save" + +msgid "shortcuts.edit-modal.cancel" +msgstr "Cancel" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:182 msgid "shortcuts.toggle-alignment" msgstr "Toggle dynamic alignment"