;; 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 Sucursal en EspaƱa SL (ns app.main.ui.shortcuts (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] [app.common.data.macros :as dm] [app.config :as cf] [app.main.data.dashboard.shortcuts.customize :as customize] [app.main.data.profile :as du] [app.main.data.shortcuts :as ds] [app.main.store :as st] [app.main.ui.context :as ctx] [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.notifications.context-notification :refer [context-notification*]] [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.strings :refer [matches-search]] [cuerdas.core :as str] [goog.events :as events] [rumext.v2 :as mf])) (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- 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 translation-keyname [type keyname] (let [translat-pre (case type :sc "shortcuts." :sec "shortcut-section." :sub-sec "shortcut-subsection.")] (tr (str translat-pre (d/name keyname))))) (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 (translation-keyname :sc conflicting-key)})))) (def ^:private import-contexts [:workspace :dashboard :viewer]) (defn- import-context-group "Imports a single context group from the payload, disabling any default shortcut whose command collides with a newly imported one, and any previously-imported entry in the same batch with a duplicate command." [group all-shortcuts] (reduce (fn [acc [command recorded-command]] (let [default-conflict (find-conflict recorded-command all-shortcuts command) acc-conflict (some (fn [[k v]] (when (and (not= k command) (= v recorded-command)) k)) acc)] (cond-> (assoc acc command recorded-command) (:key default-conflict) (assoc (:key default-conflict) "") acc-conflict (assoc acc-conflict "")))) {} group)) (defn import-custom-shortcuts [shortcuts all-shortcuts] (let [current-customs (or (get-in shortcuts [:profile :props :custom-shortcuts]) (get-in @st/state [:profile :props :custom-shortcuts]) {}) new-customs (reduce (fn [acc ctx] (if (contains? shortcuts ctx) (assoc acc ctx (import-context-group (get shortcuts ctx) all-shortcuts)) acc)) current-customs import-contexts)] (du/update-profile-props {:custom-shortcuts new-customs}))) (defn add-translation [type item] (map (fn [[k v]] [k (assoc v :translation (translation-keyname type k))]) item)) (defn shortcuts->subsections [shortcuts] (let [subsections (into #{} (mapcat :subsections) (vals shortcuts)) subsections (vec (sort-by name subsections)) get-sc-by-subsection (fn [subsection [k v]] (when (some #(= subsection %) (:subsections v)) {k v})) reduce-sc (fn [acc subsection] (let [shortcuts-by-subsection (into {} (keep (partial get-sc-by-subsection subsection) shortcuts))] (assoc acc subsection {:children shortcuts-by-subsection})))] (reduce reduce-sc (sorted-map-by (fn [a b] (compare (name a) (name b)))) subsections))) (defn build-all-shortcuts [workspace-sc dashboard-sc viewer-sc] (let [walk (fn walk [element parent-id] (if (nil? element) element (let [sorted-comp (when (sorted? element) #(compare (name %1) (name %2))) rec-fn (fn [[k item]] (let [item-id (if (nil? parent-id) [k] (conj parent-id k))] [k (assoc item :id item-id :children (walk (:children item) item-id))]))] (if sorted-comp (into (sorted-map-by sorted-comp) (map rec-fn element)) (into {} (map rec-fn element)))))) sorted-map-fn #(sorted-map-by (fn [a b] (compare (name a) (name b)))) ws-subs (->> (shortcuts->subsections workspace-sc) (add-translation :sub-sec) (into (sorted-map-fn))) db-subs (->> (shortcuts->subsections dashboard-sc) (add-translation :sub-sec) (into (sorted-map-fn))) vw-subs (->> (shortcuts->subsections viewer-sc) (add-translation :sub-sec) (into (sorted-map-fn))) basics (into {} (concat (:children (:basics ws-subs)) (:children (:basics db-subs)) (:children (:basics vw-subs)))) ws-subs (dissoc ws-subs :basics) db-subs (dissoc db-subs :basics) vw-subs (dissoc vw-subs :basics) all {:basics {:children {:none {:children basics}} :translation (tr "shortcut-section.basics")} :workspace {:children ws-subs :translation (tr "shortcut-section.workspace")} :dashboard {:children db-subs :translation (tr "shortcut-section.dashboard")} :viewer {:children vw-subs :translation (tr "shortcut-section.viewer")}} all (walk all nil) all-sc-names (map #(translation-keyname :sc %) (concat (keys workspace-sc) (keys dashboard-sc) (keys viewer-sc))) all-sub-names (map #(translation-keyname :sub-sec %) (concat (keys ws-subs) (keys db-subs) (keys vw-subs))) all-section-names (map #(translation-keyname :sec %) (keys all))] {:all-shortcuts all :all-sc-names all-sc-names :all-sub-names all-sub-names :all-section-names all-section-names})) (defn build-all-shortcuts-without-basics [workspace-sc path-sc dashboard-sc viewer-sc & {:keys [workspace-orig path-orig dashboard-orig viewer-orig]}] (let [original-commands (into {} (concat (map (fn [[k v]] [k (:command v)]) workspace-orig) (map (fn [[k v]] [k (:command v)]) path-orig) (map (fn [[k v]] [k (:command v)]) dashboard-orig) (map (fn [[k v]] [k (:command v)]) viewer-orig))) walk (fn walk [element parent-id] (if (nil? element) element (let [sorted-comp (when (sorted? element) #(compare (name %1) (name %2))) rec-fn (fn [[k item]] (let [item-id (if (nil? parent-id) [k] (conj parent-id k)) item (if (:command item) (assoc item :original-command (get original-commands k (:command item))) item)] [k (assoc item :id item-id :children (walk (:children item) item-id))]))] (if sorted-comp (into (sorted-map-by sorted-comp) (map rec-fn element)) (into {} (map rec-fn element)))))) sorted-map-fn #(sorted-map-by (fn [a b] (compare (name a) (name b)))) ws-subs (->> (shortcuts->subsections workspace-sc) (add-translation :sub-sec) (into (sorted-map-fn))) pt-subs (->> (shortcuts->subsections path-sc) (add-translation :sub-sec) (into (sorted-map-fn))) db-subs (->> (shortcuts->subsections dashboard-sc) (add-translation :sub-sec) (into (sorted-map-fn))) vw-subs (->> (shortcuts->subsections viewer-sc) (add-translation :sub-sec) (into (sorted-map-fn))) ws-subs (dissoc ws-subs :basics) pt-subs (dissoc pt-subs :basics) db-subs (dissoc db-subs :basics) vw-subs (dissoc vw-subs :basics) ws-subs (merge ws-subs pt-subs) all {:workspace {:children ws-subs :translation (tr "shortcut-section.workspace")} :dashboard {:children db-subs :translation (tr "shortcut-section.dashboard")} :viewer {:children vw-subs :translation (tr "shortcut-section.viewer")}} all (walk all nil) all-sc-names (map #(translation-keyname :sc %) (concat (keys workspace-sc) (keys path-sc) (keys dashboard-sc) (keys viewer-sc))) all-sub-names (map #(translation-keyname :sub-sec %) (concat (keys ws-subs) (keys db-subs) (keys vw-subs))) all-section-names (map #(translation-keyname :sec %) (keys all))] {:all-shortcuts all :all-sc-names all-sc-names :all-sub-names all-sub-names :all-section-names all-section-names})) (mf/defc converted-chars* [{:keys [char command class]}] (let [modified-keys {:up ds/up-arrow :down ds/down-arrow :left ds/left-arrow :right ds/right-arrow :plus "+"} macos-keys {:command "\u2318" :option "\u2325" :alt "\u2325" :delete "\u232B" :del "\u232B" :shift "\u21E7" :control "\u2303" :esc "\u238B" :enter "\u23CE"} is-macos? (cf/check-platform? :macos) char (if (contains? modified-keys (keyword char)) ((keyword char) modified-keys) char) char (if (and is-macos? (contains? macos-keys (keyword char))) ((keyword char) macos-keys) char) unique-key (str (d/name command) "-" char)] [:span {:class [class (stl/css :key)] :key unique-key} char])) (mf/defc shortcuts-keys* [{:keys [content command is-customized has-conflict?]}] (let [managed-list (if (coll? content) content (conj () content)) chars-list (map ds/split-sc managed-list) last-element (last chars-list) short-char-list (if (= 1 (count chars-list)) chars-list (drop-last chars-list)) penultimate (last short-char-list)] [:span {:class (stl/css :keys)} (for [chars short-char-list] [:* {:key (str/join chars)} (for [char chars] [:> converted-chars* {:key (dm/str char "-" (name command)) :char char :command command :class (cond has-conflict? (stl/css :conflict-key) is-customized (stl/css :customized-key))}]) (when (not= chars penultimate) [:span {:class (stl/css :space)} ","])]) (when (not= last-element penultimate) [:* [:span {:class (stl/css :space)} (tr "shortcuts.or")] (for [char last-element] [:> converted-chars* {:key (dm/str char "-" (name command)) :char char :command command :class (cond has-conflict? (stl/css :conflict-key) is-customized (stl/css :customized-key))}])])])) (mf/defc shortcut-row-editable* [{:keys [elements custom-shortcuts command-translate conflicts section-key]}] (let [{:keys [workspace-sc-trans dashboard-sc-trans viewer-sc-trans]} (mf/use-ctx ctx/shortcuts-ctx) section-shortcuts (case section-key (:workspace :path) workspace-sc-trans :dashboard dashboard-sc-trans :viewer viewer-sc-trans workspace-sc-trans) sc-by-translate (first (filter #(= (:translation (second %)) command-translate) elements)) [command command-info] sc-by-translate content (or (:show-command command-info) (:command command-info)) group-map (if (= section-key :basics) (merge (get custom-shortcuts :workspace) (get custom-shortcuts :dashboard) (get custom-shortcuts :viewer)) (get custom-shortcuts section-key)) group-map (if (map? group-map) group-map {}) customized? (contains? group-map command) has-conflict? (contains? conflicts command) is-editing* (mf/use-state false) is-editing (deref is-editing*) recording-ref (mf/use-ref nil) display-parts* (mf/use-state nil) display-parts (deref display-parts*) recorded-command* (mf/use-state nil) recorded-command (deref recorded-command*) conflict* (mf/use-state nil) conflict (deref conflict*) reset-notification* (mf/use-state nil) clean-editing-state (fn [] (reset! is-editing* false) (reset! display-parts* nil) (reset! recorded-command* nil) (reset! conflict* nil) (reset! reset-notification* nil)) effective-section-key (if (= section-key :basics) :workspace section-key) on-reset-shortcut (mf/use-fn (mf/deps effective-section-key command-info) (fn [shortcut-key] (st/emit! (customize/reset-custom-shortcut shortcut-key (:original-command command-info) effective-section-key)) (reset! reset-notification* (:original-command command-info)) (reset! recorded-command* nil) (reset! conflict* nil))) start-editing (mf/use-fn (fn [_] (reset! is-editing* true) (reset! display-parts* nil) (reset! recorded-command* nil) (reset! conflict* nil))) stop-editing (mf/use-fn (fn [event] (dom/stop-propagation event) (clean-editing-state))) disabled-shortcut (mf/use-fn (mf/deps command effective-section-key) (fn [event] (dom/prevent-default event) (st/emit! (customize/set-custom-shortcut command "" nil effective-section-key)) (clean-editing-state))) save-fn (mf/use-fn (mf/deps recorded-command conflict command effective-section-key) (fn [event] (dom/prevent-default event) (when recorded-command (st/emit! (customize/set-custom-shortcut command recorded-command (:key conflict) effective-section-key))) (clean-editing-state))) on-editable-container-blur (mf/use-fn (fn [e] (when-not (.contains (.-currentTarget e) (.-relatedTarget e)) (clean-editing-state))))] (mf/with-effect [is-editing] (when is-editing (some-> (mf/ref-val recording-ref) (dom/focus!)))) (mf/with-effect [display-parts] (when (some? display-parts) (reset! reset-notification* nil))) (mf/with-effect [@reset-notification*] (when-let [_ @reset-notification*] (let [timer (js/setTimeout #(reset! reset-notification* nil) 7000)] (fn [] (js/clearTimeout timer))))) (mf/with-effect [is-editing] (when is-editing (letfn [(on-keydown [^js event] (.preventDefault event) (.stopPropagation event) (let [recorded-command (keyboard-event->mousetrap event) parts (keyboard-event->display-parts event)] (reset! display-parts* parts) (when recorded-command (reset! recorded-command* recorded-command) (reset! conflict* (find-conflict recorded-command section-shortcuts command)))))] (->> (events/listen (mf/ref-val recording-ref) "keydown" on-keydown) (partial events/unlistenByKey))))) [:li {:class (stl/css-case :shortcuts-name-editable true :shortcuts-editing is-editing :customized customized?) :data-customized (str customized?) :data-conflict (str has-conflict?) :aria-label command-translate :key command-translate} [:button {:on-click start-editing :disabled is-editing :aria-label (dm/str "Edit " command-translate) :class (stl/css-case :shortcut-button-editable true :shortcut-button-editing is-editing)} [:span {:class (stl/css :command-name) :id (dm/str command-translate "-label")} command-translate] [:div {:class (stl/css :shortcut-actions) :aria-labelledby (dm/str command-translate "-label")} (if (and customized? (str/blank? content)) [:> icon* {:icon-id i/broken-link :size "s" :class (stl/css :shortcut-detach-icon)}] (if has-conflict? (let [conflicting-key (get conflicts command) conflict-name (translation-keyname :sc conflicting-key)] [:> tooltip* {:content (tr "shortcuts.overwritten-by" conflict-name) :id (dm/str (name command) "-conflict-tooltip") :class (stl/css :option-text)} [:> shortcuts-keys* {:content content :command command :is-customized customized? :has-conflict? has-conflict?}]]) [:> shortcuts-keys* {:content content :command command :is-customized customized? :has-conflict? has-conflict?}]))]] (when is-editing [:div {:class (stl/css :shortcut-editing) :ref recording-ref :tab-index 0 :on-blur on-editable-container-blur} [:div {:class (stl/css :shortcut-recording-area)} (if (nil? display-parts) [:span {:class (stl/css :shortcut-placeholder)} (tr "shortcuts.key-combo")] [:span {:class (stl/css :shortcut-recorded-keys)} (for [mod (:modifiers display-parts)] [:span {:class (stl/css-case :key true :customized-key customized?) :key mod} mod]) (when (:final-key display-parts) [:span {:class (stl/css-case :key true :customized-key customized?)} (:final-key display-parts)]) (when-not (:finalized? display-parts) [:span {:class (stl/css :recording-ellipsis)} "..."])])] (when-let [default-cmd @reset-notification*] [:> context-notification* {:level :info :class (stl/css :modal-reset-msg)} (tr "shortcuts.edit-modal.reset-to-default") [:> shortcuts-keys* {:content default-cmd :command (keyword "default") :is-customized false :has-conflict? false}]]) (when recorded-command (if conflict [:> context-notification* {:level :warning :class (stl/css :modal-error-msg)} (tr "shortcuts.edit-modal.conflict" (:name conflict))] [:> context-notification* {:level :success :class (stl/css :modal-success-msg)} (tr "shortcuts.edit-modal.success")])) [:div {:class (stl/css :edit-buttons)} [:div {:class (stl/css :confirmation-buttons)} [:> icon-button* {:variant "secondary" :aria-label (tr "labels.cancel") :on-click stop-editing :icon i/close :icon-size "m"}] [:> icon-button* {:variant "primary" :aria-label (tr "labels.save") :on-click save-fn :icon i/tick :icon-size "m"}]] [:div {:class (stl/css :confirmation-buttons)} [:> icon-button* {:variant "secondary" :aria-label (tr "shortcuts.disable") :on-click (fn [e] (dom/stop-propagation e) (disabled-shortcut command)) :icon i/broken-link :icon-size "m"}] (when customized? [:> icon-button* {:variant "secondary" :aria-label (tr "shortcuts.reset") :on-click (fn [e] (dom/stop-propagation e) (on-reset-shortcut command)) :icon i/reload :icon-size "m"}])]]])])) (mf/defc shortcut-row* [{:keys [elements filter-term is-match-section is-match-subsection editable? custom-shortcuts section-key conflicts hidden subsection-name]}] (let [shortcut-translations (->> elements vals (map :translation) sort) match-shortcut? (some #(matches-search % filter-term) shortcut-translations) filtered (if (and (or is-match-section is-match-subsection) (not match-shortcut?)) shortcut-translations (filter #(matches-search % filter-term) shortcut-translations)) sorted-filtered (sort filtered) trigger-ref (mf/use-ref nil)] [:ul {:class (stl/css :sub-menu) :id (dm/str (d/name subsection-name) "-subsection") :hidden hidden} (for [command-translate sorted-filtered] (let [sc-by-translate (first (filter #(= (:translation (second %)) command-translate) elements)) [command command-info] sc-by-translate content (or (:show-command command-info) (:command command-info)) group-map (if (= section-key :basics) (merge (get custom-shortcuts :workspace) (get custom-shortcuts :dashboard) (get custom-shortcuts :viewer)) (get custom-shortcuts section-key)) group-map (if (map? group-map) group-map {}) customized? (contains? group-map command) has-conflict? (contains? conflicts command)] (if editable? [:> shortcut-row-editable* {:elements elements :custom-shortcuts custom-shortcuts :section-key section-key :key command-translate :command-translate command-translate :conflicts conflicts}] [:li {:class (stl/css-case :shortcuts-name true :customized customized?) :data-customized (str customized?) :data-conflict (str has-conflict?) :aria-label command-translate :key command-translate} [:span {:class (stl/css :command-name) :id (dm/str command-translate "-label")} command-translate] [:div {:class (stl/css :shortcut-actions) :aria-labelledby (dm/str command-translate "-label")} (if (and customized? (str/blank? content)) [:> tooltip* {:content (tr "shortcuts.disabled-shortcut") :trigger-ref trigger-ref :id (dm/str command-translate "-tooltip") :class (stl/css :option-text)} [:> icon* {:icon-id i/broken-link :aria-labelledby (dm/str command-translate "-tooltip") :size "s" :ref trigger-ref :class (stl/css :shortcut-detach-icon)}]] (if has-conflict? (let [conflicting-key (get conflicts command) conflict-name (translation-keyname :sc conflicting-key)] [:> tooltip* {:content (tr "shortcuts.overwritten-by" conflict-name) :id (dm/str (name command) "-conflict-tooltip") :class (stl/css :option-text)} [:> shortcuts-keys* {:content content :command command :is-customized customized? :has-conflict? has-conflict?}]]) [:> shortcuts-keys* {:content content :command command :is-customized customized? :has-conflict? has-conflict?}]))]])))])) (mf/defc section-title* [{:keys [name is-visible is-sub on-click]}] (let [handle-key-down (fn [event] (when (or (= "Enter" (.-key event)) (= " " (.-key event))) (.preventDefault event) (on-click event)))] [:button {:class (if is-sub (stl/css :subsection-title) (stl/css :section-title)) :on-click on-click :on-key-down handle-key-down :aria-expanded is-visible :aria-controls (dm/str (str/lower name) (if is-sub "-subsection" "-section"))} [:> icon* {:icon-id (if is-visible i/arrow-down i/arrow-right) :size "s" :aria-hidden true}] [:span {:class (if is-sub (stl/css :subsection-name) (stl/css :section-name))} name]])) (mf/defc shortcut-subsection* [{:keys [subsections manage-sections filter-term is-match-section open-sections editable? custom-shortcuts section-key conflicts hidden]}] (if (= :none (first (keys subsections))) [:> shortcut-row* {:elements (:children (:none subsections)) :filter-term filter-term :is-match-section is-match-section :is-match-subsection true :editable? editable? :hidden hidden :custom-shortcuts custom-shortcuts :section-key section-key :conflicts conflicts}] [:ul {:class (stl/css :subsection-menu) :hidden hidden :id (dm/str (name section-key) "-section")} (let [sorted-subs (sort-by (fn [[_ v]] (:translation v)) subsections)] (for [[sub-name sub-info] sorted-subs] (let [visible? (some #(= % (:id sub-info)) open-sections)] [:li {:key (name sub-name)} [:h4 [:> section-title* {:name (:translation sub-info) :is-visible visible? :is-sub true :on-click (manage-sections (:id sub-info))}]] [:> shortcut-row* {:elements (:children sub-info) :subsection-name sub-name :filter-term filter-term :is-match-section is-match-section :is-match-subsection true :editable? editable? :hidden (not visible?) :custom-shortcuts custom-shortcuts :section-key section-key :conflicts conflicts}]])))])) (mf/defc shortcut-section* [{:keys [section manage-sections open-sections filter-term editable? custom-shortcuts conflicts]}] (let [[section-key section-info] section section-id (:id section-info) section-translation (:translation section-info) subsections (:children section-info) visible? (some #(= % section-id) open-sections)] [:div {:class (stl/css :section)} [:h3 {:class (stl/css :section-header)} [:> section-title* {:name section-translation :is-visible visible? :is-sub false :on-click (manage-sections section-id)}]] [:> shortcut-subsection* {:subsections subsections :open-sections open-sections :manage-sections manage-sections :is-match-section false :filter-term filter-term :editable? editable? :hidden (not visible?) :custom-shortcuts custom-shortcuts :section-key section-key :conflicts conflicts}]]))