diff --git a/CHANGES.md b/CHANGES.md index 7863ad2ec1..af5d0da55d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -27,6 +27,7 @@ - Save and restore selection state in undo/redo (by @eureka928) [Github #6007](https://github.com/penpot/penpot/issues/6007) - Fix warnings for unsupported token $type (by @Dexterity104) [Github #8790](https://github.com/penpot/penpot/issues/8790) - Add per-group add button for typographies (by @eureka928) [Github #5275](https://github.com/penpot/penpot/issues/5275) +- Add Find & Replace for text content (by @statxc) [Github #7108](https://github.com/penpot/penpot/issues/7108) - Use page name for multi-export ZIP/PDF downloads (by @Dexterity104) [Github #8773](https://github.com/penpot/penpot/issues/8773) - Make links in comments clickable (by @eureka928) [Github #1602](https://github.com/penpot/penpot/issues/1602) - Add visibility toggle for strokes (by @eureka928) [Github #7438](https://github.com/penpot/penpot/issues/7438) diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 053a963f84..d9cd5488dc 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -354,6 +354,32 @@ [k (get attrs k v)])))) +(defn content-has-text? + [content search] + (let [search-lower (str/lower search)] + (->> (node-seq is-text-node? content) + (some #(str/includes? (str/lower (:text %)) search-lower)) + (boolean)))) + +(defn replace-all-case-insensitive + [text search replacement] + (let [text-lower (str/lower text) + search-lower (str/lower search) + search-len (count search)] + (loop [result "" idx 0] + (let [found (str/index-of text-lower search-lower idx)] + (if (nil? found) + (str result (subs text idx)) + (recur (str result (subs text idx found) replacement) + (+ found search-len))))))) + +(defn replace-text-in-content + [content search replacement] + (transform-nodes + is-text-node? + (fn [node] (update node :text replace-all-case-insensitive search replacement)) + content)) + (defn content->text "Given a root node of a text content extracts the texts with its associated styles" [content] diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index e8a8a84029..75939e4858 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -1420,6 +1420,19 @@ (update [_ state] (assoc-in state [:workspace-global :clipboard-style] style)))) +(defn open-layers-search + [mode] + (ptk/reify ::open-layers-search + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:workspace-local :layers-panel-search] mode)))) + +(def clear-layers-search + (ptk/reify ::clear-layers-search + ptk/UpdateEvent + (update [_ state] + (update state :workspace-local dissoc :layers-panel-search)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Exports ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 4f4d9296cc..3c147d7a7f 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -146,6 +146,11 @@ :subsections [:edit] :fn #(st/emit! esc-pressed)} + :find {:tooltip (ds/meta "F") :command (ds/c-mod "f") :subsections [:edit] + :fn #(st/emit! (dw/open-layers-search :find))} + :find-and-replace {:tooltip (ds/meta "H") :command (ds/c-mod "h") :subsections [:edit] + :fn #(st/emit! (dw/open-layers-search :find-and-replace))} + ;; MODIFY LAYERS :rename {:tooltip (ds/alt "N") diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index cc0b46dde5..19cb56be7c 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -1155,6 +1155,35 @@ (gsh/transform-shape (ctm/change-size shape width height)))))) {:undo-group (when new-shape? id)}))))))) +(defn replace-layer-names-in-shapes + [ids search replacement] + (ptk/reify ::replace-layer-names-in-shapes + ptk/WatchEvent + (watch [_ _ _] + (let [undo-group (uuid/next)] + (rx/of + (dwsh/update-shapes + ids + (fn [shape] (update shape :name txt/replace-all-case-insensitive search replacement)) + {:attrs #{:name} :undo-group undo-group})))))) + +(defn replace-text-in-shapes + [ids search replacement] + (ptk/reify ::replace-text-in-shapes + ptk/WatchEvent + (watch [_ _ _] + (let [undo-group (uuid/next)] + (rx/of + (dwsh/update-shapes + ids + (fn [shape] + (if (and (= :text (:type shape)) (some? (:content shape))) + (let [new-content (txt/replace-text-in-content (:content shape) search replacement) + new-name (txt/generate-shape-name (txt/content->text new-content))] + (-> shape (assoc :content new-content) (assoc :name new-name))) + shape)) + {:attrs #{:content :name} :undo-group undo-group})))))) + ;; -- Text Editor v3 ;; @see texts_v3.cljs diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 4d16c79646..60d6233eb6 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -454,6 +454,12 @@ (mf/use-fn #(st/emit! (dw/select-all))) + find + (mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find)))) + + find-and-replace + (mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find-and-replace)))) + undo (mf/use-fn #(st/emit! dwu/undo)) @@ -476,6 +482,20 @@ (tr "workspace.header.menu.select-all")] [:> shortcuts* {:id :select-all}]] + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) + :on-click find + :on-key-down (fn [event] (when (kbd/enter? event) (find event))) + :id "file-menu-find"} + [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.find")] + [:> shortcuts* {:id :find}]] + + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) + :on-click find-and-replace + :on-key-down (fn [event] (when (kbd/enter? event) (find-and-replace event))) + :id "file-menu-find-and-replace"} + [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.find-and-replace")] + [:> shortcuts* {:id :find-and-replace}]] + (when can-edit [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click undo diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 49433489c6..7d41d982c1 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -11,8 +11,10 @@ [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] [app.common.types.shape :as cts] + [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.main.data.workspace :as dw] + [app.main.data.workspace.texts :as dwt] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.search-bar :refer [search-bar*]] @@ -205,61 +207,70 @@ ;; --- Layers Toolbox +(def ^:private ref:layers-panel-search + (l/derived (l/key :layers-panel-search) refs/workspace-local)) + ;; FIXME: optimize (defn- match-filters? [state [id shape]] (let [search (:search-text state) + scope (:search-scope state) filters (:filters state) filters (cond-> filters (contains? filters :shape) - (conj :rect :circle :path :bool))] + (conj :rect :circle :path :bool)) + text-match? (case scope + :canvas (and (= :text (:type shape)) + (some? (:content shape)) + (txt/content-has-text? (:content shape) search)) + (or (str/includes? (str/lower (:name shape)) (str/lower search)) + (str/includes? (str/lower (:variant-name shape)) (str/lower search)) + ;; Dev-only: allow search by id + (and *assert* (str/includes? (dm/str (:id shape)) (str/lower search)))))] (or (= uuid/zero id) - (and (or (str/includes? (str/lower (:name shape)) (str/lower search)) - (str/includes? (str/lower (:variant-name shape)) (str/lower search)) - ;; Only for local development we allow search for ids. Otherwise will be hard - ;; search for numbers or single letter shape names (ie: "A") - (and *assert* - (str/includes? (dm/str (:id shape)) (str/lower search)))) + (and text-match? (or (empty? filters) - (and (contains? filters :component) - (contains? shape :component-id)) - (and (contains? filters :image) - (some? (cts/has-images? shape))) - + (and (contains? filters :component) (contains? shape :component-id)) + (and (contains? filters :image) (some? (cts/has-images? shape))) (let [direct-filters (into #{} (filter #{:frame :rect :circle :path :bool :text}) filters)] (contains? direct-filters (:type shape))) (and (contains? filters :group) - (and (cfh/group-shape? shape) - (not (contains? shape :component-id)) - (or (not (contains? shape :masked-group)) - (false? (:masked-group shape))))) - (and (contains? filters :mask) - (true? (:masked-group shape)))))))) + (cfh/group-shape? shape) + (not (contains? shape :component-id)) + (or (not (contains? shape :masked-group)) + (false? (:masked-group shape)))) + (and (contains? filters :mask) (true? (:masked-group shape)))))))) (defn use-search [page objects] - (let [state* (mf/use-state - #(do {:show-search false - :show-menu false - :search-text "" - :filters #{} - :num-items 100})) - - state (deref state*) - current-filters (:filters state) - current-items (:num-items state) - current-search (:search-text state) - show-menu? (:show-menu state) - show-search? (:show-search state) + (let [state* (mf/use-state + #(do {:show-search false + :find-replace-mode? false + :search-scope :layers + :show-menu false + :search-text "" + :replace-text "" + :filters #{} + :num-items 100 + :current-match-idx 0})) + layers-search-request (mf/deref ref:layers-panel-search) + state (deref state*) + current-filters (:filters state) + current-items (:num-items state) + current-search (:search-text state) + replace-text (:replace-text state) + show-menu? (:show-menu state) + show-search? (:show-search state) + find-replace-mode? (:find-replace-mode? state) + search-scope (:search-scope state) + current-match-idx (:current-match-idx state) clear-search-text (mf/use-fn - #(swap! state* assoc :search-text "" :num-items 100)) - + #(swap! state* assoc :search-text "" :num-items 100 :current-match-idx 0)) toggle-filters - (mf/use-fn - #(swap! state* update :show-menu not)) + (mf/use-fn #(swap! state* update :show-menu not)) on-toggle-filters-click (mf/use-fn @@ -268,18 +279,26 @@ (toggle-filters))) hide-menu - (mf/use-fn - #(swap! state* assoc :show-menu false)) + (mf/use-fn #(swap! state* assoc :show-menu false)) on-key-down - (mf/use-fn - (fn [event] - (when (kbd/esc? event) (hide-menu)))) + (mf/use-fn (fn [event] (when (kbd/esc? event) (hide-menu)))) update-search-text (mf/use-fn (fn [value _event] - (swap! state* assoc :search-text value :num-items 100))) + (swap! state* assoc :search-text value :num-items 100 :current-match-idx 0))) + + update-replace-text + (mf/use-fn (fn [value _event] (swap! state* assoc :replace-text value))) + + clear-replace-text + (mf/use-fn #(swap! state* assoc :replace-text "")) + + set-search-scope + (mf/use-fn + (fn [scope] + (swap! state* assoc :search-scope scope :num-items 100 :current-match-idx 0))) toggle-search (mf/use-fn @@ -288,30 +307,23 @@ (dom/blur! node) (swap! state* (fn [state] (-> state - (assoc :search-text "") - (assoc :filters #{}) - (assoc :show-menu false) - (assoc :num-items 100) + (assoc :search-text "" :replace-text "" :filters #{}) + (assoc :show-menu false :find-replace-mode? false) + (assoc :search-scope :layers :num-items 100 :current-match-idx 0) (update :show-search not))))))) remove-filter (mf/use-fn (fn [event] - (let [fkey (-> (dom/get-current-target event) - (dom/get-data "filter") - (keyword))] + (let [fkey (-> (dom/get-current-target event) (dom/get-data "filter") (keyword))] (swap! state* (fn [state] - (-> state - (update :filters disj fkey) - (assoc :num-items 100))))))) + (-> state (update :filters disj fkey) (assoc :num-items 100))))))) add-filter (mf/use-fn (fn [event] (dom/stop-propagation event) - (let [key (-> (dom/get-current-target event) - (dom/get-data "filter") - (keyword))] + (let [key (-> (dom/get-current-target event) (dom/get-data "filter") (keyword))] (swap! state* (fn [state] (-> state (update :filters conj key) @@ -331,6 +343,65 @@ filtered-objects-total (count filtered-objects-all) + canvas-match-ids + (mf/with-memo [objects current-search search-scope] + (when (and (= :canvas search-scope) (d/not-empty? current-search)) + (reduce-kv (fn [acc id shape] + (cond-> acc + (and (= :text (:type shape)) + (some? (:content shape)) + (txt/content-has-text? (:content shape) current-search)) + (conj id))) + [] objects))) + + layer-match-ids + (mf/with-memo [objects current-search search-scope] + (when (and (= :layers search-scope) (d/not-empty? current-search)) + (reduce-kv (fn [acc id shape] + (cond-> acc + (str/includes? (str/lower (:name shape)) (str/lower current-search)) + (conj id))) + [] objects))) + + text-match-ids (if (= :canvas search-scope) canvas-match-ids layer-match-ids) + text-match-count (count text-match-ids) + safe-match-idx (if (pos? text-match-count) (mod current-match-idx text-match-count) 0) + + navigate-next + (mf/use-fn + (mf/deps text-match-count) + (fn [_] + (when (pos? text-match-count) + (swap! state* update :current-match-idx + (fn [idx] (mod (inc idx) text-match-count)))))) + + navigate-prev + (mf/use-fn + (mf/deps text-match-count) + (fn [_] + (when (pos? text-match-count) + (swap! state* update :current-match-idx + (fn [idx] (mod (+ (dec idx) text-match-count) text-match-count)))))) + + handle-replace + (mf/use-fn + (mf/deps text-match-ids safe-match-idx replace-text current-search search-scope) + (fn [_] + (when (and (pos? text-match-count) (d/not-empty? current-search)) + (let [id (nth text-match-ids safe-match-idx)] + (if (= :canvas search-scope) + (st/emit! (dwt/replace-text-in-shapes [id] current-search replace-text)) + (st/emit! (dwt/replace-layer-names-in-shapes [id] current-search replace-text))))))) + + handle-replace-all + (mf/use-fn + (mf/deps text-match-ids replace-text current-search search-scope) + (fn [_] + (when (and (pos? text-match-count) (d/not-empty? current-search)) + (if (= :canvas search-scope) + (st/emit! (dwt/replace-text-in-shapes text-match-ids current-search replace-text)) + (st/emit! (dwt/replace-layer-names-in-shapes text-match-ids current-search replace-text)))))) + filtered-objects (mf/with-memo [active? filtered-objects-all current-items] (when active? @@ -352,6 +423,16 @@ (events/unlistenByKey key1) (events/unlistenByKey key2)))) + (mf/with-effect [layers-search-request] + (when (some? layers-search-request) + (let [replace-mode? (= layers-search-request :find-and-replace)] + (swap! state* (fn [s] + (-> s + (assoc :show-search true :find-replace-mode? replace-mode?) + (assoc :search-scope (if replace-mode? :canvas :layers)) + (assoc :search-text "" :replace-text "" :current-match-idx 0))))) + (st/emit! dw/clear-layers-search))) + [filtered-objects handle-show-more #(mf/html @@ -363,17 +444,62 @@ :on-clear clear-search-text :placeholder (tr "workspace.sidebar.layers.search")} [:button {:on-click on-toggle-filters-click - :class (stl/css-case - :filter-button true - :opened show-menu? - :active active?)} + :class (stl/css-case :filter-button true :opened show-menu? :active active?)} [:> icon* {:icon-id i/filter}]]] - [:> icon-button* {:variant "ghost" :aria-label (tr "labels.close") :on-click toggle-search :icon i/close}]] + [:div {:class (stl/css :search-scope-row)} + [:label {:class (stl/css-case :scope-option true :scope-selected (= :canvas search-scope))} + [:span {:class (stl/css-case :scope-radio true :scope-radio-checked (= :canvas search-scope))}] + [:input {:type "radio" :name "search-scope" :class (stl/css :scope-radio-input) + :checked (= :canvas search-scope) + :on-change (fn [_] (set-search-scope :canvas))}] + [:span {:class (stl/css :scope-label)} + (tr "workspace.sidebar.layers.search-scope-canvas")]] + [:label {:class (stl/css-case :scope-option true :scope-selected (= :layers search-scope))} + [:span {:class (stl/css-case :scope-radio true :scope-radio-checked (= :layers search-scope))}] + [:input {:type "radio" :name "search-scope" :class (stl/css :scope-radio-input) + :checked (= :layers search-scope) + :on-change (fn [_] (set-search-scope :layers))}] + [:span {:class (stl/css :scope-label)} + (tr "workspace.sidebar.layers.search-scope-layers")]]] + + (when ^boolean find-replace-mode? + [:* + [:div {:class (stl/css :tool-window-bar :replace-row)} + [:div {:class (stl/css :replace-input-wrapper)} + [:input {:class (stl/css :replace-input) + :value replace-text + :placeholder (tr "workspace.sidebar.layers.replace-placeholder") + :on-change (fn [event] + (update-replace-text (dom/get-target-val event) event))}] + (when (not= "" replace-text) + [:button {:class (stl/css :clear-icon) :on-click clear-replace-text} + [:> icon* {:icon-id i/delete-text :size "s"}]])] + (when (d/not-empty? current-search) + (if (pos? text-match-count) + [:div {:class (stl/css :match-navigation)} + [:span {:class (stl/css :match-count)} + (dm/str (inc safe-match-idx) " / " text-match-count)] + [:> icon-button* {:variant "ghost" :aria-label (tr "labels.previous") + :on-click navigate-prev :icon i/arrow-up}] + [:> icon-button* {:variant "ghost" :aria-label (tr "labels.next") + :on-click navigate-next :icon i/arrow-down}]] + [:span {:class (stl/css :no-matches)} + (tr "workspace.sidebar.layers.no-matches")]))] + [:div {:class (stl/css :replace-actions-row)} + [:button {:class (stl/css :replace-button) + :on-click handle-replace + :disabled (or (zero? text-match-count) (str/empty? current-search))} + (tr "workspace.sidebar.layers.replace")] + [:button {:class (stl/css :replace-button) + :on-click handle-replace-all + :disabled (or (zero? text-match-count) (str/empty? current-search))} + (tr "workspace.sidebar.layers.replace-all")]]]) + [:div {:class (stl/css :active-filters)} (for [fkey current-filters] (let [fname (d/name fkey) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.scss b/frontend/src/app/main/ui/workspace/sidebar/layers.scss index 234d1cee61..7fb33a72da 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.scss @@ -246,6 +246,160 @@ scrollbar-gutter: stable; } +.replace-row { + padding: 0 deprecated.$s-12; + gap: deprecated.$s-4; +} + +.search-scope-row { + display: flex; + gap: deprecated.$s-16; + padding: deprecated.$s-4 deprecated.$s-12 deprecated.$s-8; + align-items: center; +} + +.scope-option { + display: flex; + align-items: center; + gap: deprecated.$s-6; + cursor: pointer; +} + +.scope-radio { + width: deprecated.$s-12; + height: deprecated.$s-12; + border: deprecated.$s-1 solid var(--color-foreground-secondary); + border-radius: 50%; + background-color: transparent; + flex-shrink: 0; +} + +.scope-radio-checked { + border-color: var(--color-accent-primary); + background-color: var(--color-accent-primary); + box-shadow: inset 0 0 0 deprecated.$s-2 var(--color-background-primary); +} + +.scope-radio-input { + display: none; +} + +.scope-label { + @include deprecated.bodySmallTypography; + + color: var(--color-foreground-secondary); + cursor: pointer; +} + +.scope-selected .scope-label { + color: var(--color-foreground-primary); +} + +.replace-actions-row { + display: flex; + gap: deprecated.$s-4; + padding: 0 deprecated.$s-12 deprecated.$s-8; +} + +.replace-input-wrapper { + @include deprecated.flexCenter; + + flex: 1; + height: deprecated.$s-32; + border: deprecated.$s-1 solid var(--search-bar-input-border-color); + border-radius: deprecated.$br-8; + background-color: var(--search-bar-input-background-color); + + &:hover { + border: deprecated.$s-1 solid var(--input-border-color-hover); + background-color: var(--input-background-color-hover); + + .replace-input { + background-color: var(--input-background-color-hover); + } + } + + &:focus-within { + background-color: var(--input-background-color-active); + color: var(--input-foreground-color-active); + border: deprecated.$s-1 solid var(--input-border-color-focus); + + .replace-input { + background-color: var(--input-background-color-active); + } + } +} + +.replace-input { + width: 100%; + height: 100%; + margin: 0 deprecated.$s-8; + border: 0; + background-color: var(--input-background-color); + font-size: deprecated.$fs-12; + color: var(--input-foreground-color); + border-radius: deprecated.$br-8; + + &:focus { + outline: none; + } +} + +.replace-button { + @include deprecated.bodySmallTypography; + @include deprecated.buttonStyle; + + flex: 1; + height: deprecated.$s-28; + padding: 0 deprecated.$s-8; + border: deprecated.$s-1 solid var(--color-background-tertiary); + border-radius: deprecated.$br-8; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); + white-space: nowrap; + text-transform: uppercase; + + &:hover:not(:disabled) { + border: deprecated.$s-1 solid var(--input-border-color-hover); + background-color: var(--input-background-color-hover); + } + + &:disabled { + opacity: 0.4; + cursor: default; + } +} + +.match-navigation { + display: flex; + align-items: center; + gap: deprecated.$s-2; + flex-shrink: 0; +} + +.match-count { + @include deprecated.bodySmallTypography; + + color: var(--color-foreground-secondary); + white-space: nowrap; +} + +.no-matches { + @include deprecated.bodySmallTypography; + + color: var(--color-foreground-secondary); + white-space: nowrap; + flex-shrink: 0; +} + +.clear-icon { + @extend %button-tag; + + flex: 0 0 deprecated.$s-32; + height: 100%; + color: var(--color-icon-default); +} + .element-list { display: grid; position: relative; diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 2a481b0d0d..9ce50c445c 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -5863,6 +5863,14 @@ msgstr "Redo" msgid "workspace.header.menu.select-all" msgstr "Select all" +#: src/app/main/ui/workspace/main_menu.cljs +msgid "workspace.header.menu.find" +msgstr "Find" + +#: src/app/main/ui/workspace/main_menu.cljs +msgid "workspace.header.menu.find-and-replace" +msgstr "Find and Replace" + #: src/app/main/ui/workspace/main_menu.cljs:423 msgid "workspace.header.menu.show-artboard-names" msgstr "Show boards names" @@ -7966,6 +7974,34 @@ msgstr "Shapes" msgid "workspace.sidebar.layers.texts" msgstr "Texts" +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.replace" +msgstr "Replace" + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.replace-all" +msgstr "Replace all" + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.replace-placeholder" +msgstr "Replace with..." + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.match-count" +msgstr "%s of %s" + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.no-matches" +msgstr "No matches" + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.search-scope-layers" +msgstr "Search layers" + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.search-scope-canvas" +msgstr "Search on canvas" + #: src/app/main/ui/inspect/attributes/svg.cljs:56, src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:101 msgid "workspace.sidebar.options.svg-attrs.title" msgstr "Imported SVG Attributes"