From ce5276065ecf8e861d83527a3b7561d04b5c33ff Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Thu, 22 Oct 2026 12:24:12 +0200 Subject: [PATCH] :bug: Skip unknown entries and reserve keys in shortcut settings (#12473) * :bug: Skip unknown entries when importing shortcuts The shortcuts import rejected the whole file when it held a single entry it did not know, so a profile exported from an older Penpot version, or edited by hand, could not be imported at all. The spec in #9924 asks to skip unknown keys, tell the user which ones, and apply the rest. Importing now keeps the entries that map to a known shortcut with a valid key combination and skips the rest: unknown contexts, unknown shortcut keys, and commands that are not a key combination. The import modal lists the skipped entries. The file is only rejected when it is not a map or holds nothing that can be applied. A context whose entries are all skipped is left out: an empty context clears the user's shortcuts on import, so keeping it would wipe them. valid-command? reads a command the way mousetrap does, so it accepts every default shortcut and rejects strings mousetrap could never match. AI-assisted-by: claude-opus-5-5 * :paperclip: Sort requires in shortcuts settings test cljfmt expects the namespace requires in alphabetical order. AI-assisted-by: claude-opus-5-5 * :sparkles: Reserve cancel, copy and paste shortcuts Cancel, copy and paste are no longer customizable, in the workspace and in the path editor, which shares their shortcut keys. Their key combinations (Escape, Ctrl/Cmd+C, Ctrl/Cmd+V) are now reserved: the shortcut recorder warns and blocks saving one of them for another action, and the import skips entries that use them. Reserved combinations come from the non-customizable workspace shortcuts, so marking another one as non-customizable reserves its key too. The path editor's cancel also binds V, the move tool key, so path shortcuts are left out. Combinations compare the way mousetrap matches them: aliases and modifier order do not matter. apply-custom-overrides now ignores overrides of non-customizable shortcuts. An override saved before this change would otherwise still apply, with no way to reset it from the settings page. AI-assisted-by: claude-opus-5-5 --- frontend/src/app/main/data/shortcuts.cljs | 5 +- .../main/data/workspace/path/shortcuts.cljs | 3 + .../app/main/data/workspace/shortcuts.cljs | 2 + .../settings/import_shortcuts_diff_modal.cljs | 14 +- .../settings/import_shortcuts_diff_modal.scss | 19 ++ .../src/app/main/ui/settings/shortcuts.cljs | 59 ++++- frontend/src/app/main/ui/shortcuts.cljs | 134 ++++++++++- .../ui/settings_shortcuts_test.cljs | 217 +++++++++++++++++- frontend/translations/en.po | 8 + frontend/translations/es.po | 8 + 10 files changed, 447 insertions(+), 22 deletions(-) diff --git a/frontend/src/app/main/data/shortcuts.cljs b/frontend/src/app/main/data/shortcuts.cljs index 7181f5ebb3..9a045d4995 100644 --- a/frontend/src/app/main/data/shortcuts.cljs +++ b/frontend/src/app/main/data/shortcuts.cljs @@ -187,8 +187,11 @@ shortcuts (reduce-kv (fn [acc sc-key new-command] + ;; A non-customizable shortcut ignores overrides stored before it + ;; became so: the settings page no longer offers to reset them. (if (and (contains? acc sc-key) - (not (:disabled (get acc sc-key)))) + (not (:disabled (get acc sc-key))) + (not (false? (:customizable (get acc sc-key))))) (-> acc (assoc-in [sc-key :original-command] (get-in acc [sc-key :command])) (assoc-in [sc-key :command] new-command) diff --git a/frontend/src/app/main/data/workspace/path/shortcuts.cljs b/frontend/src/app/main/data/workspace/path/shortcuts.cljs index bd924df5fe..21f1c87960 100644 --- a/frontend/src/app/main/data/workspace/path/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/path/shortcuts.cljs @@ -127,6 +127,7 @@ :label (fn [] (tr "shortcuts.copy")) :command (ds/c-mod "c") :subsections [:path-editor] + :customizable false :fn #(st/emit! (drp/copy-selected-nodes))} :cut {:tooltip (ds/meta "X") @@ -139,6 +140,7 @@ :label (fn [] (tr "shortcuts.paste")) :command (ds/c-mod "v") :subsections [:path-editor] + :customizable false :fn #(st/emit! (drp/paste-nodes))} :duplicate {:tooltip (ds/meta "D") @@ -176,6 +178,7 @@ :label (fn [] (tr "shortcuts.escape")) :command ["escape" "v"] :section [:workspace] + :customizable false :fn #(st/emit! (esc-pressed))} ;; Reuses the `:start-editing` key (instead of adding "enter" to diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index bdbd3cedaf..17246e5889 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -94,6 +94,7 @@ :command (ds/c-mod "c") :subsections [:edit] :section [:workspace] + :customizable false :fn #(st/emit! (dw/copy-selected))} :copy-link {:tooltip (ds/shift (ds/alt "C")) @@ -178,6 +179,7 @@ :command "escape" :subsections [:edit] :section [:workspace] + :customizable false :fn #(st/emit! :interrupt (dwdc/clear-drawing) (dw/deselect-all true))} :find {:tooltip (ds/meta "F") :command (ds/c-mod "f") :subsections [:edit] diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs index a7462d9380..43478a33f1 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.cljs @@ -65,7 +65,7 @@ (mf/defc import-shortcuts-diff-modal {::mf/register modal/components ::mf/register-as :import-shortcuts-diff-modal} - [{:keys [imported-shortcuts custom-shortcuts all-shortcuts-raw]}] + [{:keys [imported-shortcuts skipped-entries custom-shortcuts all-shortcuts-raw]}] (let [diff-entries (mf/with-memo [imported-shortcuts custom-shortcuts] (->> (compute-diff imported-shortcuts custom-shortcuts) @@ -145,7 +145,17 @@ :is-customized true :has-conflict? false}])]]])))]] [:div {:class (stl/css :no-changes)} - (tr "import-shortcuts.no-changes")])] + (tr "import-shortcuts.no-changes")]) + + (when (seq skipped-entries) + [:div {:class (stl/css :skipped-entries)} + [:div {:class (stl/css :modal-content-text)} + (tr "import-shortcuts.skipped-entries")] + [:ul {:class (stl/css :skipped-entries-list)} + (for [entry skipped-entries] + [:li {:key entry + :class (stl/css :skipped-entry)} + entry])]])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} diff --git a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss index e810578008..c28d5de804 100644 --- a/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss +++ b/frontend/src/app/main/ui/settings/import_shortcuts_diff_modal.scss @@ -165,3 +165,22 @@ text-align: center; padding-block: var(--sp-xl); } + +.skipped-entries { + display: flex; + flex-direction: column; + gap: var(--sp-s); +} + +.skipped-entries-list { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.skipped-entry { + @include t.use-typography("code-font"); + + color: var(--color-foreground-secondary); + overflow-wrap: anywhere; +} diff --git a/frontend/src/app/main/ui/settings/shortcuts.cljs b/frontend/src/app/main/ui/settings/shortcuts.cljs index 2b246339b2..841841ac5b 100644 --- a/frontend/src/app/main/ui/settings/shortcuts.cljs +++ b/frontend/src/app/main/ui/settings/shortcuts.cljs @@ -121,6 +121,53 @@ [:dashboard {:optional true} (context-schema :dashboard)] [:viewer {:optional true} (context-schema :viewer)]])) +(defn- entry-path + [& segments] + (str/join "/" (map d/name segments))) + +(defn- sanitize-context + "Keeps the entries of one imported context that map to a known shortcut key + with a valid command; every other entry goes to :skipped." + [result ctx entries known-keys] + (let [viable (reduce-kv + (fn [viable k command] + (if (and (contains? known-keys k) + (ss/valid-command? command) + (not (ss/reserved-command? command))) + (assoc viable k command) + viable)) + {} + entries) + skipped (->> (keys entries) + (remove #(contains? viable %)) + (map #(entry-path ctx %)))] + (cond-> (update result :skipped into skipped) + ;; A context left empty by skipped entries is dropped: an empty context + ;; clears the user's shortcuts on import, an explicit {} still does. + (or (seq viable) (empty? entries)) + (assoc-in [:shortcuts ctx] viable)))) + +(defn sanitize-imported-shortcuts + "Splits an imported custom-shortcuts payload into the entries Penpot can + apply and the ones it must skip: unknown contexts, unknown or + non-customizable shortcut keys, commands that are not a key combination + (see `app.main.ui.shortcuts/valid-command?`) and reserved combinations + (see `app.main.ui.shortcuts/reserved-command?`). + + Returns {:shortcuts :skipped [\"context/key\" ...]}, or + nil when the payload is not a map." + [data] + (when (map? data) + (-> (reduce-kv + (fn [result ctx entries] + (let [known-keys (get known-shortcut-keys ctx)] + (if (and (some? known-keys) (map? entries)) + (sanitize-context result ctx entries known-keys) + (update result :skipped conj (entry-path ctx))))) + {:shortcuts {} :skipped []} + data) + (update :skipped (comp vec sort))))) + (defn- flatten-humanize "Turns Malli's humanized explain output into a flat list of {:path :message} maps." ([human] (flatten-humanize [] human)) @@ -450,12 +497,16 @@ (rx/subs! (fn [content] (try - (let [shortcuts (js->clj (.parse js/JSON content) - :keywordize-keys true) - validation (validate-imported-shortcuts shortcuts)] - (if (:valid? validation) + (let [data (js->clj (.parse js/JSON content) + :keywordize-keys true) + {:keys [shortcuts skipped]} (sanitize-imported-shortcuts data)] + ;; Rejects the file only when nothing in it can be applied. + (if (and (some? shortcuts) + (or (seq shortcuts) (empty? skipped)) + (:valid? (validate-imported-shortcuts shortcuts))) (st/emit! (modal/show {:type :import-shortcuts-diff-modal :imported-shortcuts shortcuts + :skipped-entries skipped :custom-shortcuts custom-shortcuts :all-shortcuts-raw all-shortcuts-raw})) (st/emit! (ntf/error (tr "import-shortcuts.invalid-file"))))) diff --git a/frontend/src/app/main/ui/shortcuts.cljs b/frontend/src/app/main/ui/shortcuts.cljs index 1520f3258b..30aed17a2c 100644 --- a/frontend/src/app/main/ui/shortcuts.cljs +++ b/frontend/src/app/main/ui/shortcuts.cljs @@ -68,6 +68,122 @@ "Meta" "meta" " " "space"}) +;; Mirrors how mousetrap reads a bind string (`_keysFromString` and +;; `_getKeyInfo` in @penpot/mousetrap), so a command is valid exactly when +;; mousetrap can match it. + +(def ^:private command-aliases + {"ctrl" "ctrl" + "control" "ctrl" + "alt" "alt" + "option" "alt" + "shift" "shift" + "meta" "meta" + "command" "meta" + "return" "enter" + "enter" "enter" + "esc" "escape" + "escape" "escape" + "del" "del" + "delete" "del" + "ins" "ins" + "insert" "ins" + "space" "space" + "plus" "+"}) + +(def ^:private command-modifiers + #{"ctrl" "alt" "shift" "meta"}) + +(def ^:private command-named-keys + (into #{} (remove command-modifiers) (vals key-name-map))) + +(defn- canonical-command-key + [token] + (cond + ;; `mod` follows the platform, like `ds/c-mod`. + (= "mod" token) + (if (cf/check-platform? :macos) "meta" "ctrl") + + (contains? command-aliases token) + (get command-aliases token) + + (= 1 (count token)) + (str/lower token) + + :else + (str/lower (get key-name-map token token)))) + +(defn- command-step-keys + "Splits one step of a command (\"ctrl+shift+z\") into canonical keys. + A lone \"+\" is the plus key, and \"++\" ends a step with it." + [step] + (if (= "+" step) + ["+"] + (->> (.split (str/replace step #"\+{2}" "+plus") "+") + (mapv canonical-command-key)))) + +(defn- final-command-key? + [k] + (or (= 1 (count k)) + (contains? command-named-keys k) + ;; Two-digit alternatives first: cljs `re-matches` does not + ;; backtrack into a longer alternative. + (some? (re-matches #"f(?:1[0-9]|2[0-4]|[1-9])" k)) + (some? (re-matches #"num[0-9]" k)))) + +(defn- valid-command-step? + "A lone modifier is a valid step: `:show-measure` binds a bare `alt`." + [step-keys] + (let [final-key (peek step-keys)] + (if (contains? command-modifiers final-key) + (= 1 (count step-keys)) + (and (every? command-modifiers (pop step-keys)) + (final-command-key? final-key))))) + +(defn valid-command? + "True when `command` is a key combination mousetrap can match: steps + separated by single spaces, each a lone modifier or modifiers joined + with \"+\" ending with a single character, a named key (`escape`, `up`, + ...), a function key (`f1`..`f24`) or a numpad digit (`num0`..`num9`). + The empty string is valid: it disables the shortcut." + [command] + (and (string? command) + (or (= "" command) + (every? (comp valid-command-step? command-step-keys) + (.split command " "))))) + +(defn- canonical-command-step + "One step in the form mousetrap matches on: canonical keys with the + modifiers sorted, so `Shift+Ctrl+Z` and `ctrl+shift+z` compare equal." + [step] + (let [step-keys (command-step-keys step) + final-key (peek step-keys)] + (str/join "+" (conj (vec (sort (pop step-keys))) final-key)))) + +(defn- reserved-command-steps + "Key combinations of the non-customizable workspace shortcuts (cancel, + copy, paste): assigning them to another action would bind one key to two + actions. The path editor's cancel also binds `v`, the move tool key, + so path shortcuts are left out." + [] + (into #{} + (comp (filter #(false? (:customizable (val %)))) + (mapcat #(let [command (:command (val %))] + (if (vector? command) command [command]))) + (mapcat #(.split % " ")) + (map canonical-command-step)) + wsc/shortcuts)) + +(defn reserved-command? + "True when any step of `command` is a combination Penpot keeps for a + non-customizable shortcut." + [command] + (and (string? command) + (not= "" command) + (let [reserved (reserved-command-steps)] + (some? (some #(contains? reserved (canonical-command-step %)) + (.split command " ")))))) + (defn- event->native "Unwraps a goog.events.BrowserEvent to the native event, since the wrapper copies `key` but not `code`." @@ -601,12 +717,16 @@ effective-section-key)) (clean-editing-state))) + reserved? (and (some? recorded-command) + (not reset-pending) + (reserved-command? recorded-command)) + save-fn (mf/use-fn - (mf/deps recorded-command conflict command effective-section-key) + (mf/deps recorded-command reserved? conflict command effective-section-key) (fn [event] (dom/prevent-default event) - (when recorded-command + (when (and recorded-command (not reserved?)) (if (deref reset-pending*) (st/emit! (customize/reset-custom-shortcut command recorded-command effective-section-key)) (st/emit! (customize/set-custom-shortcut @@ -710,9 +830,16 @@ :has-conflict? false}]]) (when (and recorded-command (not reset-pending)) - (if conflict + (cond + reserved? + [:> context-notification* {:level :warning :class (stl/css :modal-error-msg)} + (tr "shortcuts.edit-modal.reserved")] + + conflict [:> context-notification* {:level :warning :class (stl/css :modal-error-msg)} (tr "shortcuts.edit-modal.conflict" (:name conflict))] + + :else [:> context-notification* {:level :success :class (stl/css :modal-success-msg)} (tr "shortcuts.edit-modal.success")])) [:div {:class (stl/css :edit-buttons)} @@ -725,6 +852,7 @@ [:> icon-button* {:variant "primary" :aria-label (tr "labels.save") :on-click save-fn + :disabled reserved? :icon i/tick :icon-size "m"}]] [:div {:class (stl/css :confirmation-buttons)} diff --git a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs index fd82bfe17d..3ed7e035c9 100644 --- a/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs +++ b/frontend/test/frontend_tests/ui/settings_shortcuts_test.cljs @@ -1,8 +1,12 @@ (ns frontend-tests.ui.settings-shortcuts-test (:require [app.config :as cf] + [app.main.data.dashboard.shortcuts :as dsc] [app.main.data.profile :as du] [app.main.data.shortcuts :as ds] + [app.main.data.viewer.shortcuts :as vsc] + [app.main.data.workspace.path.shortcuts :as psc] + [app.main.data.workspace.shortcuts :as wsc] [app.main.ui.settings.import-shortcuts-diff-modal :as diff-modal] [app.main.ui.settings.restore-shortcuts-modal :as restore-modal] [app.main.ui.settings.shortcuts :as sut] @@ -14,21 +18,21 @@ (t/deftest validate-imported-shortcuts-accepts-valid-workspace (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape "escape" + {:workspace {:draw-rect "shift+r" :increase-zoom "+" :zoom-lense-decrease "alt+z"}})))) (t/deftest validate-imported-shortcuts-accepts-optional-contexts (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape "escape"} + {:workspace {:draw-rect "shift+r"} :dashboard {:toggle-theme "alt+m"} :viewer {:next-frame "right"}})))) (t/deftest validate-imported-shortcuts-accepts-empty-string-command (t/is (= {:valid? true} (sut/validate-imported-shortcuts - {:workspace {:escape ""}})))) + {:workspace {:draw-rect ""}})))) (t/deftest validate-imported-shortcuts-rejects-nil (let [result (sut/validate-imported-shortcuts nil)] @@ -52,19 +56,19 @@ (t/is (some #(= "workspace" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-non-string-command - (let [result (sut/validate-imported-shortcuts {:workspace {:escape 123}})] + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect 123}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) - (t/is (some #(= "workspace/escape" (:path %)) (:errors result))))) + (t/is (some #(= "workspace/draw-rect" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-vector-command - (let [result (sut/validate-imported-shortcuts {:workspace {:escape ["ctrl+e"]}})] + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect ["ctrl+e"]}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) - (t/is (some #(= "workspace/escape" (:path %)) (:errors result))))) + (t/is (some #(= "workspace/draw-rect" (:path %)) (:errors result))))) (t/deftest validate-imported-shortcuts-rejects-unknown-top-level-key - (let [result (sut/validate-imported-shortcuts {:workspace {:escape "escape"} + (let [result (sut/validate-imported-shortcuts {:workspace {:draw-rect "shift+r"} :unknown-context {}})] (t/is (= false (:valid? result))) (t/is (seq (:errors result))) @@ -87,6 +91,195 @@ (t/is (= false (:valid? (sut/validate-imported-shortcuts #{:workspace})))) (t/is (= false (:valid? (sut/validate-imported-shortcuts (fn [])))))) +(t/deftest sanitize-imported-shortcuts-skips-unknown-action-keys + (t/is (= {:shortcuts {:workspace {:draw-rect "shift+r"}} + :skipped ["workspace/not-a-real-shortcut"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r" + :not-a-real-shortcut "x"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-unknown-contexts + (t/is (= {:shortcuts {:workspace {:draw-rect "shift+r"}} + :skipped ["unknown-context"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r"} + :unknown-context {:draw-rect "shift+r"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-non-string-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect 123 + :increase-zoom ["+"] + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-invalid-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "foo+bar" + :increase-zoom "ctrl+" + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest cancel-copy-and-paste-are-not-customizable + (t/testing "in the workspace and in the path editor, which share the + shortcut keys of the workspace context" + (doseq [shortcuts [wsc/shortcuts psc/shortcuts] + k [:escape :copy :paste]] + (t/is (false? (get-in shortcuts [k :customizable])) + (str k))))) + +(t/deftest sanitize-imported-shortcuts-skips-non-customizable-keys + (t/testing "cancel, copy and paste cannot be imported, even with a free + key combination" + (t/is (= {:shortcuts {} + :skipped ["workspace/copy" "workspace/escape" "workspace/paste"]} + (sut/sanitize-imported-shortcuts + {:workspace {:escape "shift+escape" + :copy "ctrl+shift+1" + :paste "ctrl+shift+2"}}))))) + +(t/deftest apply-custom-overrides-skips-non-customizable-shortcuts + (t/testing "an override stored before a shortcut became non-customizable + is ignored, since the settings page can no longer reset it" + (let [shortcuts {:escape {:command "escape" :customizable false} + :undo {:command "ctrl+z"}} + result (ds/apply-custom-overrides shortcuts + {:workspace {:escape "shift+escape" + :undo "ctrl+u"}} + :workspace)] + (t/is (= "escape" (get-in result [:escape :command]))) + (t/is (= "ctrl+u" (get-in result [:undo :command])))))) + +(t/deftest sanitize-imported-shortcuts-skips-reserved-commands + (t/is (= {:shortcuts {:workspace {:zoom-lense-decrease "alt+z"}} + :skipped ["workspace/draw-rect" "workspace/increase-zoom"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "escape" + :increase-zoom "ctrl+v" + :zoom-lense-decrease "alt+z"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-copy-combination + (t/is (= {:shortcuts {:workspace {:draw-rect "ctrl+alt+c"}} + :skipped ["workspace/draw-ellipse"]} + (sut/sanitize-imported-shortcuts + {:workspace {:draw-ellipse "ctrl+c" + :draw-rect "ctrl+alt+c"}})))) + +(t/deftest sanitize-imported-shortcuts-skips-non-map-context + (t/is (= {:shortcuts {:viewer {:next-frame "right"}} + :skipped ["workspace"]} + (sut/sanitize-imported-shortcuts + {:workspace "not-a-map" + :viewer {:next-frame "right"}})))) + +(t/deftest sanitize-imported-shortcuts-drops-context-without-viable-entries + (t/testing "a context whose entries are all skipped is left out, so the + import does not clear the user's shortcuts in that context" + (t/is (= {:shortcuts {} + :skipped ["viewer/not-a-real-shortcut"]} + (sut/sanitize-imported-shortcuts + {:viewer {:not-a-real-shortcut "x"}}))))) + +(t/deftest sanitize-imported-shortcuts-keeps-empty-context + (t/is (= {:shortcuts {:workspace {}} + :skipped []} + (sut/sanitize-imported-shortcuts {:workspace {}})))) + +(t/deftest sanitize-imported-shortcuts-returns-nil-for-non-maps + (t/is (nil? (sut/sanitize-imported-shortcuts nil))) + (t/is (nil? (sut/sanitize-imported-shortcuts "shortcuts"))) + (t/is (nil? (sut/sanitize-imported-shortcuts 42))) + (t/is (nil? (sut/sanitize-imported-shortcuts [])))) + +(t/deftest sanitize-imported-shortcuts-output-is-valid + (let [{:keys [shortcuts]} (sut/sanitize-imported-shortcuts + {:workspace {:draw-rect "shift+r" + :not-a-real-shortcut "x" + :increase-zoom 1} + :dashboard "not-a-map" + :viewer {:next-frame "right"} + :unknown-context {}})] + (t/is (= {:valid? true} + (sut/validate-imported-shortcuts shortcuts))))) + +(t/deftest valid-command-accepts-key-combinations + (t/are [command] (true? (ui-shortcuts/valid-command? command)) + "" + "a" + "ctrl+shift+k" + "Ctrl+s" + "command+alt+ñ" + "+" + "shift++" + "ctrl+plus" + "shift+\"" + "g v" + "escape" + "Escape" + "f1" + "f12" + "f24" + "num5" + "alt")) + +(t/deftest valid-command-rejects-other-strings + (t/are [command] (false? (ui-shortcuts/valid-command? command)) + "foo" + "foo+bar" + "a+b" + "ctrl+foo" + "ctrl+" + "ctrl+shift" + "++" + "f25" + " " + " a" + "a " + "a b" + "🙂" + nil + 123)) + +(t/deftest reserved-command-matches-non-customizable-combinations + (t/testing "cancel, copy and paste keep their keys; spelling and modifier + order do not matter, and a reserved step taints a sequence" + (with-redefs [cf/check-platform? (constantly false)] + (t/are [command] (true? (ui-shortcuts/reserved-command? command)) + "escape" + "Escape" + "esc" + "ctrl+c" + "Control+C" + "mod+c" + "ctrl+v" + "Control+V" + "mod+v" + "g escape" + "ctrl+c g")))) + +(t/deftest reserved-command-ignores-other-combinations + (with-redefs [cf/check-platform? (constantly false)] + (t/are [command] (false? (ui-shortcuts/reserved-command? command)) + "" + "v" + "c" + "shift+escape" + "ctrl+shift+v" + "ctrl+shift+c" + "ctrl+alt+c" + "shift+alt+c" + nil))) + +(t/deftest valid-command-accepts-every-default-command + (t/testing "a profile exported by Penpot always holds valid commands" + (doseq [shortcuts [psc/shortcuts wsc/shortcuts dsc/shortcuts vsc/shortcuts] + [shortcut-key {:keys [command]}] shortcuts + command (if (vector? command) command [command]) + :when (some? command)] + (t/is (ui-shortcuts/valid-command? command) + (str shortcut-key " -> " (pr-str command)))))) + (t/deftest import-custom-shortcuts-updates-multiple-contexts (let [imported {:workspace {:add-comment "alt+c"} :viewer {:select-all "ctrl+alt+a"}} @@ -173,17 +366,17 @@ (t/is (= "ctrl+shift+a" (:imported select-all-entry)))))) (t/deftest compute-diff-shows-empty-string-as-disabled-shortcut - (let [imported {:workspace {:escape ""}} + (let [imported {:workspace {:draw-rect ""}} current {} diff (diff-modal/compute-diff imported current)] (t/is (pos? (count diff))) - (let [entry (first (filter #(= :escape (:key %)) diff))] + (let [entry (first (filter #(= :draw-rect (:key %)) diff))] (t/is (= :workspace (:context entry))) (t/is (= "" (:imported entry)))))) (t/deftest compute-diff-excludes-unchanged-shortcuts - (let [imported {:workspace {:escape ""}} - current {:workspace {:escape ""}} + (let [imported {:workspace {:draw-rect ""}} + current {:workspace {:draw-rect ""}} diff (diff-modal/compute-diff imported current)] (t/is (empty? diff)))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 014f5b622b..c1699b9564 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2181,6 +2181,10 @@ msgstr "This file is not a valid shortcuts export." msgid "import-shortcuts.no-changes" msgstr "No differences found between the current and imported shortcuts." +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:153 +msgid "import-shortcuts.skipped-entries" +msgstr "These entries can't be imported: they don't match any Penpot shortcut or use a reserved key combination." + #: src/app/main/ui/components/color_input.cljs:31 msgid "inspect.attributes.color" msgstr "Color" @@ -4937,6 +4941,10 @@ msgstr "Edit %s" msgid "shortcuts.edit-modal.conflict" msgstr "Already assigned to \"%s\". Saving will move it to this action." +#: src/app/main/ui/shortcuts.cljs:836 +msgid "shortcuts.edit-modal.reserved" +msgstr "This combination is reserved by Penpot and can't be assigned." + #: src/app/main/ui/shortcuts.cljs:636 msgid "shortcuts.edit-modal.reset-pending" msgstr "Saving will reset this shortcut to default:" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 6eaf06da2a..b579adfd77 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2207,6 +2207,10 @@ msgstr "Este archivo no es una exportación de atajos válida." msgid "import-shortcuts.no-changes" msgstr "No se encontraron diferencias entre los atajos actuales y los importados." +#: src/app/main/ui/settings/import_shortcuts_diff_modal.cljs:153 +msgid "import-shortcuts.skipped-entries" +msgstr "Estas entradas no se pueden importar: no corresponden a ningún atajo de Penpot o usan una combinación reservada." + #: src/app/main/ui/components/color_input.cljs:31 msgid "inspect.attributes.color" msgstr "Color" @@ -4991,6 +4995,10 @@ msgstr "Editar %s" msgid "shortcuts.edit-modal.conflict" msgstr "Ya asignado a \"%s\". Al guardar, pasará a esta acción." +#: src/app/main/ui/shortcuts.cljs:836 +msgid "shortcuts.edit-modal.reserved" +msgstr "Penpot reserva esta combinación y no se puede asignar." + #: src/app/main/ui/shortcuts.cljs:636 msgid "shortcuts.edit-modal.reset-pending" msgstr "Al guardar, este atajo volverá a su valor por defecto:"