penpot/frontend/src/app/main/ui/settings/shortcuts.cljs
2026-07-29 12:35:27 +02:00

525 lines
23 KiB
Clojure

(ns app.main.ui.settings.shortcuts
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.i18n :refer [tr]]
[app.common.json :as json]
[app.common.schema :as sm]
[app.main.data.dashboard.shortcuts :as dsc]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[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.store :as st]
[app.main.ui.components.dropdown-menu :refer [dropdown-menu*
dropdown-menu-item*]]
[app.main.ui.components.search-bar :refer [search-bar*]]
[app.main.ui.context :as ctx]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.typography :as t]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]]
[app.main.ui.ds.product.empty-state :refer [empty-state*]]
[app.main.ui.settings.import-shortcuts-diff-modal]
[app.main.ui.settings.restore-shortcuts-modal]
[app.main.ui.shortcuts :as ss]
[app.util.dom :as dom]
[app.util.strings :refer [matches-search]]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[malli.error :as me]
[rumext.v2 :as mf]))
(mf/defc search-section*
[{:keys [filter-term on-search-term-change on-search-clear-click on-restore-all show-restore-all has-custom-shortcuts]}]
[:div {:class (stl/css :shortcuts-search-section)}
[:> search-bar* {:on-change on-search-term-change
:on-clear on-search-clear-click
:value filter-term
:placeholder (tr "shortcuts.title")
:icon-id i/search
:auto-focus true}]
(when (and show-restore-all has-custom-shortcuts)
[:> button*
{:variant "secondary"
:on-click on-restore-all
:class (stl/css :restore-all-button)
:icon i/reload}
(tr "dashboard.restore-all-deleted-button")])])
(defn- filter-shortcuts-tree
[tree shortcut-filter search-term]
(let [sorted-comp (when (sorted? tree)
#(compare (name %1) (name %2)))
result (keep (fn [[k node]]
(let [children (:children node)]
(cond
(map? children)
(let [filtered-children
(filter-shortcuts-tree children shortcut-filter search-term)]
(when (seq filtered-children)
[k (assoc node :children filtered-children)]))
(shortcut-filter k node search-term)
[k node])))
tree)]
(if sorted-comp
(into (sorted-map-by sorted-comp) result)
(into {} result))))
(defn- collect-open-section-ids
[tree]
(letfn [(walk [nodes]
(mapcat
(fn [node]
(let [children (:children node)]
(concat
(when (:children node)
[(:id node)])
(when (map? children)
(walk (vals children))))))
nodes))]
(->> (walk (vals tree))
(remove nil?)
set)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Imported shortcuts JSON validation
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private known-shortcut-keys
"Known shortcut keys per context, derived from the default shortcuts maps."
{:workspace (set (keys wsc/shortcuts))
:dashboard (set (keys dsc/shortcuts))
:viewer (set (keys vsc/shortcuts))})
(def ^:private schema:imported-shortcuts
"Malli schema for an imported custom-shortcuts payload.
The expected shape is a map of shortcut contexts to maps of shortcut-key -> command-string.
Only keys that exist in the default shortcuts for each context are accepted."
(letfn [(context-schema [ctx]
(into [:map {:closed true}]
(map (fn [k] [k {:optional true} :string]))
(get known-shortcut-keys ctx)))]
[:map {:closed true}
[:workspace {:optional true} (context-schema :workspace)]
[:dashboard {:optional true} (context-schema :dashboard)]
[:viewer {:optional true} (context-schema :viewer)]]))
(defn- flatten-humanize
"Turns Malli's humanized explain output into a flat list of {:path :message} maps."
([human] (flatten-humanize [] human))
([prefix human]
(cond
(or (string? human) (symbol? human) (keyword? human))
[{:path (str/join "/" (map #(if (keyword? %) (name %) (str %)) prefix))
:message (str human)}]
(map? human)
(mapcat (fn [[k v]] (flatten-humanize (conj prefix k) v)) human)
(sequential? human)
(mapcat #(flatten-humanize prefix %) human)
:else [])))
(defn validate-imported-shortcuts
"Validates an imported custom-shortcuts JSON payload.
Returns {:valid? true} when the payload conforms to the expected shape, or
{:valid? false :errors [{:path \"...\" :message \"...\"} ...]} otherwise.
Never throws for validation errors."
[data]
(try
(if-not (map? data)
{:valid? false
:errors [{:path ""
:message "Expected a map of shortcut contexts"}]}
(let [explain (sm/explain schema:imported-shortcuts data)]
(if (nil? explain)
{:valid? true}
{:valid? false
:errors (-> explain me/humanize flatten-humanize vec)})))
(catch :default e
{:valid? false
:errors [{:path ""
:message (str "Validation failed: " (.-message e))}]})))
(mf/defc shortcuts-list*
[{:keys [shortcuts open-sections filter-term custom-shortcuts editable? manage-sections conflicts]}]
(let [sections (mf/with-memo [shortcuts]
(keep (fn [section]
(when (seq (get-in section [1 :children]))
section))
shortcuts))]
[:div {:class (stl/css :shortcuts-list)
:aria-label (tr "shortcuts.title")}
(for [section sections]
(let [[section-key _] section]
[:> ss/shortcut-section* {:key (name section-key)
:section section
:manage-sections manage-sections
:open-sections open-sections
:filter-term filter-term
:editable? editable?
:custom-shortcuts custom-shortcuts
:conflicts conflicts}]))]))
(mf/defc shortcuts-tab-section*
[{:keys [shortcut-filter show-restore-all on-restore-all empty-str custom-shortcuts conflicts expand-all-by-default
open-sections update-open-sections]}]
(let [{:keys [all-shortcuts]} (mf/use-ctx ctx/shortcuts-ctx)
filter-term* (mf/use-state "")
filter-term (deref filter-term*)
section-has-content?
(fn [section]
(let [children (:children section)]
(if (and (= (count children) 1) (contains? children :none))
(seq (:children (:none children)))
(seq children))))
all-shortcuts (into {} (filter (fn [[_ v]] (section-has-content? v)) all-shortcuts))
filtered-shortcuts (filter-shortcuts-tree all-shortcuts shortcut-filter filter-term)
expanded?* (mf/use-state false)
expanded? (deref expanded?*)
expand-all-sections
(fn []
(let [all-ids (collect-open-section-ids filtered-shortcuts)]
(update-open-sections (if (seq all-ids) (vec all-ids) [[:workspace]]))))
on-search-term-change
(mf/use-fn
(mf/deps all-shortcuts shortcut-filter)
(fn [term]
(reset! filter-term* term)
(if (str/blank? term)
(if expand-all-by-default
(expand-all-sections)
(update-open-sections [[:workspace]]))
(let [filtered-tree (filter-shortcuts-tree all-shortcuts shortcut-filter term)
open-ids (collect-open-section-ids filtered-tree)]
(update-open-sections (vec open-ids))))))
on-search-clear-click
(mf/use-fn
(fn [_]
(reset! filter-term* "")
(if expand-all-by-default
(expand-all-sections)
(update-open-sections [[:workspace]]))))
manage-sections
(fn [item]
(fn [event]
(dom/stop-propagation event)
(let [is-present? (some #(= % item) open-sections)
new-value (if is-present?
(filterv (fn [element] (not= element item)) open-sections)
(conj open-sections item))]
(update-open-sections new-value))))
has-custom-shortcuts (some #(seq (val %)) custom-shortcuts)
empty-str
(if (and has-custom-shortcuts (not (str/blank? filter-term)))
(tr "shortcuts.no-shortcuts")
empty-str)]
(mf/with-effect [filtered-shortcuts filter-term expand-all-by-default]
(when (and expand-all-by-default (str/blank? filter-term) (not expanded?))
(let [all-ids (collect-open-section-ids filtered-shortcuts)]
(when (seq all-ids)
(update-open-sections (vec all-ids))
(reset! expanded?* true)))))
[:div {:class (stl/css :shortcuts-section)}
[:> search-section* {:filter-term filter-term
:on-search-term-change on-search-term-change
:on-search-clear-click on-search-clear-click
:on-restore-all on-restore-all
:show-restore-all show-restore-all
:has-custom-shortcuts has-custom-shortcuts}]
(if (seq filtered-shortcuts)
[:> shortcuts-list* {:shortcuts filtered-shortcuts
:open-sections open-sections
:filter-term filter-term*
:custom-shortcuts custom-shortcuts
:editable? true
:manage-sections manage-sections
:conflicts conflicts}]
[:> empty-state* {:text empty-str
:class (stl/css :shortcuts-empty-state)}])]))
(mf/defc shortcuts-page*
[{:keys [profile]}]
(let [section* (mf/use-state :all)
section (deref section*)
show-menu* (mf/use-state false)
show-menu? (deref show-menu*)
open-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! show-menu* true)))
close-menu
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! show-menu* false)))
input-ref (mf/use-ref nil)
tabs
(mf/with-memo []
[{:label (tr "labels.all")
:id "all"}
{:label (tr "shortcuts.personalized")
:data-testid "personalized"
:id "personalized"}
{:label (tr "shortcuts.disabled")
:data-testid "disabled"
:id "disabled"}])
handle-change-tab
(mf/use-fn
(fn [new-section]
(reset! section* (keyword new-section))))
open-sections-by-tab* (mf/use-state {})
open-sections-by-tab (deref open-sections-by-tab*)
make-update-open-sections
(fn [tab-key]
(fn [new-val]
(swap! open-sections-by-tab* assoc tab-key new-val)))
custom-shortcuts (get-in profile [:props :custom-shortcuts])
has-custom-shortcuts (some #(seq (val %)) custom-shortcuts)
path-shortcuts-custom (ds/apply-custom-overrides
psc/shortcuts
custom-shortcuts
:workspace)
path-shortcuts-with-translation (->> path-shortcuts-custom
(ss/add-translation :sc)
(into {}))
workspace-shortcuts-custom (ds/apply-custom-overrides
wsc/shortcuts
custom-shortcuts
:workspace)
workspace-shortcuts-with-translation (->> workspace-shortcuts-custom
(ss/add-translation :sc)
(into {}))
dashboard-shortcuts-custom (ds/apply-custom-overrides
dsc/shortcuts
custom-shortcuts :dashboard)
dashboard-shortcuts-with-translation (->> dashboard-shortcuts-custom
(ss/add-translation :sc)
(into {}))
viewer-shortcuts-custom (ds/apply-custom-overrides
vsc/shortcuts
custom-shortcuts
:viewer)
viewer-shortcuts-with-translation (->> viewer-shortcuts-custom
(ss/add-translation :sc)
(into {}))
all-shortcuts-raw (d/deep-merge
workspace-shortcuts-custom
path-shortcuts-custom
dashboard-shortcuts-custom
viewer-shortcuts-custom)
{:keys [all-shortcuts]}
(ss/build-all-shortcuts-without-basics
workspace-shortcuts-with-translation
path-shortcuts-with-translation
dashboard-shortcuts-with-translation
viewer-shortcuts-with-translation
:workspace-orig wsc/shortcuts
:path-orig psc/shortcuts
:dashboard-orig dsc/shortcuts
:viewer-orig vsc/shortcuts)
shortcuts-ctx-value
{:workspace-sc-trans (d/deep-merge workspace-shortcuts-with-translation path-shortcuts-with-translation)
:dashboard-sc-trans dashboard-shortcuts-with-translation
:viewer-sc-trans viewer-shortcuts-with-translation
:all-shortcuts all-shortcuts
:all-sc-raw all-shortcuts-raw}
on-restore-all
(mf/use-fn
(mf/deps custom-shortcuts)
(fn [event]
(dom/stop-propagation event)
(st/emit! (modal/show {:type :restore-all-modal
:custom-shortcuts custom-shortcuts}))))
on-import-file
(mf/use-fn
(fn [_]
(dom/click (mf/ref-val input-ref))))
shortcuts-json
(mf/with-memo
[custom-shortcuts]
(some-> custom-shortcuts
(json/encode :key-fn d/name :indent 2)))
on-export
(mf/use-fn
(mf/deps shortcuts-json has-custom-shortcuts)
(fn []
(when has-custom-shortcuts
(->> (wapi/create-blob shortcuts-json "application/json")
(dom/trigger-download "penpot-shortcuts.json")))))
on-file-selected
(mf/use-fn
(mf/deps all-shortcuts-raw custom-shortcuts)
(fn [event]
(let [file (-> (dom/get-target event)
(dom/get-files)
(first))]
(->> (wapi/read-file-as-text file)
(rx/subs!
(fn [content]
(try
(let [shortcuts (js->clj (.parse js/JSON content)
:keywordize-keys true)
validation (validate-imported-shortcuts shortcuts)]
(if (:valid? validation)
(st/emit! (modal/show {:type :import-shortcuts-diff-modal
:imported-shortcuts shortcuts
:custom-shortcuts custom-shortcuts
:all-shortcuts-raw all-shortcuts-raw}))
(st/emit! (ntf/error (tr "errors.invalid-data")))))
(catch :default _
(st/emit! (ntf/error (tr "errors.invalid-data"))))))))
(-> (mf/ref-val input-ref)
(dom/set-value! "")))))]
[:section {:class (stl/css :shortcuts-page)
:aria-label (tr "shortcuts.page")}
[:div {:class (stl/css :shortcuts-content)}
[:> heading* {:level 2
:typography t/title-large
:class (stl/css :page-title)}
(tr "label.shortcuts")]
[:> text* {:class (stl/css :shortcuts-description)
:as "span"
:typography t/body-small}
[:> icon* {:icon-id i/info
:class (stl/css :shortcuts-description-icon)}]
(tr "shortcuts.reload-hint")]
[:> (mf/provider ctx/shortcuts-ctx) {:value shortcuts-ctx-value}
[:div {:class (stl/css :shortcuts-content)}
[:> tab-switcher* {:tabs tabs
:selected (name section)
:on-change handle-change-tab
:class (stl/css :shortcuts-switcher)}
(case section
:all
[:> shortcuts-tab-section* {:shortcut-filter (fn [_ shortcut search-term]
(or (str/blank? search-term)
(matches-search (:translation shortcut) search-term)))
:show-restore-all true
:empty-str (tr "shortcuts.no-shortcuts")
:on-restore-all on-restore-all
:custom-shortcuts custom-shortcuts
:open-sections (get open-sections-by-tab :all [[:workspace]])
:update-open-sections (make-update-open-sections :all)}]
:personalized
[:> shortcuts-tab-section* {:shortcut-filter (fn [shortcut-key shortcut search-term]
(let [shortcut-group (first (:section shortcut))
group-map (get custom-shortcuts shortcut-group)
group-map (if (map? group-map) group-map {})
customized? (and (contains? group-map shortcut-key)
(not (str/blank? (get group-map shortcut-key))))]
(and customized?
(or (str/blank? search-term)
(matches-search (:translation shortcut) search-term)))))
:show-restore-all true
:empty-str (tr "shortcuts.no-personalized")
:on-restore-all on-restore-all
:expand-all-by-default true
:custom-shortcuts custom-shortcuts
:open-sections (get open-sections-by-tab :personalized [[:workspace]])
:update-open-sections (make-update-open-sections :personalized)}]
:disabled
[:> shortcuts-tab-section* {:shortcut-filter (fn [shortcut-key shortcut search-term]
(let [shortcut-group (first (:section shortcut))
group-map (get custom-shortcuts shortcut-group)
group-map (if (map? group-map) group-map {})
in-group? (contains? group-map shortcut-key)
blank? (str/blank? (get group-map shortcut-key))]
(and in-group? blank?
(or (str/blank? search-term)
(matches-search (:translation shortcut) search-term)))))
:show-restore-all true
:empty-str (tr "shortcuts.no-disabled")
:on-restore-all on-restore-all
:expand-all-by-default true
:custom-shortcuts custom-shortcuts
:open-sections (get open-sections-by-tab :disabled [[:workspace]])
:update-open-sections (make-update-open-sections :disabled)}])]]
[:div {:class (stl/css :shortcuts-page-footer)}
[:div {:class (stl/css :shortcuts-info)}
[:div {:class (stl/css :shortcuts-info-wrapper)}
[:div {:class (stl/css :dot-wrapper)}
[:div {:class (stl/css :shortcuts-customized-dot)}]]
[:p {:class (stl/css :shortcuts-text)}
(tr "shortcuts.personalized")]]
[:div {:class (stl/css :shortcuts-info-wrapper)}
[:> icon* {:class (stl/css :shortcuts-not-assigned-icon)
:icon-id i/broken-link}]
[:p {:class (stl/css :shortcuts-text)}
(tr "shortcuts.disabled")]]]
[:div {:class (stl/css :export-wrapper)}
[:> button* {:variant "secondary"
:on-click open-menu
:icon i/import-export}
(tr "shortcuts.import-export")]
[:> dropdown-menu* {:show show-menu?
:on-close close-menu
:id "tokens-menu"
:class (stl/css :import-export-menu)}
[:> dropdown-menu-item* {:class (stl/css :import-export-menu-item)
:on-click on-import-file}
[:div {:class (stl/css :import-menu-item)}
[:div (tr "labels.import")]]]
(when has-custom-shortcuts
[:> dropdown-menu-item* {:class (stl/css :import-export-menu-item)
:on-click on-export}
(tr "labels.export")])]]
[:input
{:type "file"
:accept ".json,application/json"
:ref input-ref
:style {:display "none"}
:on-change on-file-selected}]]]]]))