Polish workspace find and replace UX

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andres Gonzalez
2026-05-15 14:32:21 +02:00
committed by Luis de Dios
parent 5b7c732449
commit 480d457235
9 changed files with 223 additions and 60 deletions

View File

@@ -1493,18 +1493,47 @@
(update [_ state]
(assoc-in state [:workspace-global :clipboard-style] style))))
(defn open-layers-search
(defn- layers-search-config
[mode]
(ptk/reify ::open-layers-search
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:workspace-local :layers-panel-search] mode))))
{:open? true
:mode mode
:scope (if (= mode :find-and-replace) :canvas :layers)
:find-replace-mode? (= mode :find-and-replace)})
(def clear-layers-search
(ptk/reify ::clear-layers-search
(defn- layers-search-active?
[current target]
(and (:open? current false)
(= (:scope current) (:scope target))
(= (:find-replace-mode? current) (:find-replace-mode? target))))
(defn open-layers-search
([mode] (open-layers-search mode nil))
([mode options]
(let [force? (boolean (:force? options))]
(ptk/reify ::open-layers-search
ptk/UpdateEvent
(update [_ state]
(let [target (layers-search-config mode)
current (get-in state [:workspace-local :layers-search])]
(if (and (not force?)
(layers-search-active? current target))
(update state :workspace-local dissoc :layers-search)
(assoc-in state [:workspace-local :layers-search] target))))))))
(def close-layers-search
(ptk/reify ::close-layers-search
ptk/UpdateEvent
(update [_ state]
(update state :workspace-local dissoc :layers-panel-search))))
(update state :workspace-local dissoc :layers-search))))
(defn update-layers-search-scope
[scope]
(ptk/reify ::update-layers-search-scope
ptk/UpdateEvent
(update [_ state]
(if (get-in state [:workspace-local :layers-search])
(assoc-in state [:workspace-local :layers-search :scope] scope)
state))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Exports
@@ -1565,6 +1594,8 @@
;; Highlight
(dm/export dwh/highlight-shape)
(dm/export dwh/dehighlight-shape)
(dm/export dwh/set-search-match-highlight)
(dm/export dwh/clear-search-match-highlight)
;; Shape flags
(dm/export dwsh/update-shape-flags)

View File

@@ -27,3 +27,29 @@
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-local :highlighted] disj id))))
(defn set-search-match-highlight
"Highlight the active find/replace match on canvas and sidebar."
[current-id match-ids]
(dm/assert! (uuid? current-id))
(let [match-ids (set match-ids)]
(ptk/reify ::set-search-match-highlight
ptk/UpdateEvent
(update [_ state]
(let [highlighted (-> (get-in state [:workspace-local :highlighted] #{})
(set/difference match-ids)
(conj current-id))]
(-> state
(assoc-in [:workspace-local :search-match-highlight] current-id)
(assoc-in [:workspace-local :highlighted] highlighted)))))))
(defn clear-search-match-highlight
[match-ids]
(let [match-ids (set match-ids)]
(ptk/reify ::clear-search-match-highlight
ptk/UpdateEvent
(update [_ state]
(-> state
(update-in [:workspace-local :highlighted]
#(set/difference (or % #{}) match-ids))
(update :workspace-local dissoc :search-match-highlight))))))

View File

@@ -1206,18 +1206,24 @@
[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}))))))
(watch [_ state _]
(let [undo-group (uuid/next)
update-event
(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})]
(rx/concat
(rx/of update-event)
(if (features/active-feature? state "render-wasm/v1")
(->> (rx/from ids)
(rx/map #(dwwt/resize-wasm-text-debounce % {:undo-group undo-group})))
(rx/empty)))))))
;; -- Text Editor v3

View File

@@ -13,7 +13,8 @@
[rumext.v2 :as mf]))
(mf/defc search-bar*
[{:keys [id class value placeholder icon-id auto-focus on-change on-clear on-submit children]}]
[{:keys [id class value placeholder icon-id auto-focus input-ref
on-change on-clear on-submit on-key-down children]}]
(let [handle-change
(mf/use-fn
(mf/deps on-change)
@@ -31,8 +32,11 @@
handle-key-down
(mf/use-fn
(mf/deps on-submit)
(mf/deps on-submit on-key-down)
(fn [event]
(when (fn? on-key-down)
(on-key-down event))
(let [enter? (kbd/enter? event)
esc? (kbd/esc? event)
node (dom/get-target event)]
@@ -53,6 +57,7 @@
:size "s"
:class (stl/css :icon)}])
[:input {:id id
:ref input-ref
:class (stl/css :search-input)
:on-change handle-change
:value value

View File

@@ -474,10 +474,10 @@
#(st/emit! (dw/select-all)))
find
(mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find))))
(mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find {:force? true}))))
find-and-replace
(mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find-and-replace))))
(mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find-and-replace {:force? true}))))
undo
(mf/use-fn

View File

@@ -48,7 +48,10 @@
(let [{:keys [enter leave]} @sidebar-hover-queue
enter (set/difference enter leave)
leave (set/difference leave enter)]
leave (set/difference leave enter)
search-match (get-in @st/state [:workspace-local :search-match-highlight])
leave (cond-> leave
(some? search-match) (disj search-match))]
(reset! sidebar-hover-queue {:enter #{} :leave #{}})
(reset! sidebar-hover-pending? false)

View File

@@ -30,6 +30,7 @@
[app.util.keyboard :as kbd]
[app.util.rxops :refer [throttle-fn]]
[app.util.shape-icon :as usi]
[app.util.timers :as ts]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[goog.events :as events]
@@ -175,14 +176,16 @@
{::mf/wrap [mf/memo #(mf/throttle % 300)]
::mf/private true}
[{:keys [objects parent-size]}]
(let [selected (use-selected-shapes)
root (get objects uuid/zero)]
(let [selected (use-selected-shapes)
highlighted (mf/deref ref:highlighted-shapes)
root (get objects uuid/zero)]
[:ul {:class (stl/css :element-list)}
(for [[index id] (d/enumerate (:shapes root))]
(when-let [obj (get objects id)]
[:> layer-item*
{:item obj
:selected selected
:highlighted highlighted
:index index
:objects objects
:key id
@@ -208,8 +211,8 @@
;; --- Layers Toolbox
(def ^:private ref:layers-panel-search
(l/derived (l/key :layers-panel-search) refs/workspace-local))
(def ^:private ref:layers-search
(l/derived (l/key :layers-search) refs/workspace-local))
;; FIXME: optimize
(defn- match-filters?
@@ -254,7 +257,7 @@
:filters #{}
:num-items 100
:current-match-idx 0}))
layers-search-request (mf/deref ref:layers-panel-search)
layers-search (mf/deref ref:layers-search)
state (deref state*)
current-filters (:filters state)
current-items (:num-items state)
@@ -265,6 +268,7 @@
find-replace-mode? (:find-replace-mode? state)
search-scope (:search-scope state)
current-match-idx (:current-match-idx state)
search-input-ref (mf/use-ref nil)
clear-search-text
(mf/use-fn
@@ -296,22 +300,41 @@
clear-replace-text
(mf/use-fn #(swap! state* assoc :replace-text ""))
f-key? (kbd/is-key-ignore-case? "f")
h-key? (kbd/is-key-ignore-case? "h")
handle-find-shortcut-keydown
(mf/use-fn
(fn [event]
(when (kbd/mod? event)
(cond
(f-key? event)
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(st/emit! (dw/open-layers-search :find)))
(h-key? event)
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(st/emit! (dw/open-layers-search :find-and-replace)))))))
set-search-scope
(mf/use-fn
(fn [scope]
(swap! state* assoc :search-scope scope :num-items 100 :current-match-idx 0)))
(swap! state* assoc :search-scope scope :num-items 100 :current-match-idx 0)
(st/emit! (dw/update-layers-search-scope scope))))
toggle-search
(mf/use-fn
(mf/deps show-search?)
(fn [event]
(let [node (dom/get-current-target event)]
(dom/blur! node)
(swap! state* (fn [state]
(-> state
(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)))))))
(if show-search?
(st/emit! dw/close-layers-search)
(st/emit! (dw/open-layers-search :find {:force? true}))))))
remove-filter
(mf/use-fn
@@ -403,6 +426,24 @@
(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))))))
on-replace-keydown
(mf/use-fn
(mf/deps handle-replace)
(fn [event]
(when (or (kbd/enter? event) (kbd/space? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(handle-replace event))))
on-replace-all-keydown
(mf/use-fn
(mf/deps handle-replace-all)
(fn [event]
(when (or (kbd/enter? event) (kbd/space? event))
(dom/prevent-default event)
(dom/stop-propagation event)
(handle-replace-all event))))
filtered-objects
(mf/with-memo [active? filtered-objects-all current-items]
(when active?
@@ -424,15 +465,44 @@
(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)]
(mf/with-effect [layers-search]
(if-let [{:keys [open? find-replace-mode? scope]} layers-search]
(when open?
(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)))
(let [mode-changed? (not= (:find-replace-mode? s) find-replace-mode?)
opening? (not (:show-search s))]
(-> s
(assoc :show-search true
:find-replace-mode? find-replace-mode?
:search-scope scope)
(cond-> (or opening? mode-changed?)
(assoc :search-text "" :replace-text "" :current-match-idx 0)))))))
(swap! state* (fn [state]
(-> state
(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)
(assoc :show-search false))))))
(mf/with-effect [(get layers-search :scope)]
(when (and layers-search (:open? layers-search))
(swap! state* assoc :search-scope (:scope layers-search))))
(mf/with-effect [layers-search show-search?]
(when (and layers-search (:open? layers-search) show-search?)
(ts/raf
(fn []
(when-let [node (mf/ref-val search-input-ref)]
(dom/focus! node))))))
(mf/with-effect [find-replace-mode? show-search? safe-match-idx text-match-ids]
(let [match-ids text-match-ids]
(when (and find-replace-mode? show-search? (seq match-ids))
(let [current-id (nth match-ids safe-match-idx)]
(st/emit! (dw/set-search-match-highlight current-id match-ids))))
(fn []
(when (seq match-ids)
(st/emit! (dw/clear-search-match-highlight match-ids))))))
[filtered-objects
handle-show-more
@@ -440,9 +510,11 @@
(if show-search?
[:*
[:div {:class (stl/css :tool-window-bar :search)}
[:> search-bar* {:on-change update-search-text
[:> search-bar* {:input-ref search-input-ref
:on-change update-search-text
:value current-search
:on-clear clear-search-text
:on-key-down handle-find-shortcut-keydown
: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?)}
@@ -453,20 +525,20 @@
: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")]]]
(tr "workspace.sidebar.layers.search-scope-layers")]]
[: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")]]]
(when ^boolean find-replace-mode?
[:*
@@ -476,7 +548,8 @@
:value replace-text
:placeholder (tr "workspace.sidebar.layers.replace-placeholder")
:on-change (fn [event]
(update-replace-text (dom/get-target-val event) event))}]
(update-replace-text (dom/get-target-val event) event))
:on-key-down handle-find-shortcut-keydown}]
(when (not= "" replace-text)
[:button {:class (stl/css :clear-icon) :on-click clear-replace-text}
[:> icon* {:icon-id i/delete-text :size "s"}]])]
@@ -492,12 +565,16 @@
[: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)
[:button {:type "button"
:class (stl/css :replace-button)
:on-click handle-replace
:on-key-down on-replace-keydown
:disabled (or (zero? text-match-count) (str/empty? current-search))}
(tr "workspace.sidebar.layers.replace")]
[:button {:class (stl/css :replace-button)
[:button {:type "button"
:class (stl/css :replace-button)
:on-click handle-replace-all
:on-key-down on-replace-all-keydown
:disabled (or (zero? text-match-count) (str/empty? current-search))}
(tr "workspace.sidebar.layers.replace-all")]]])

View File

@@ -111,6 +111,8 @@
(tr "shortcuts.duplicate")
(tr "shortcuts.escape")
(tr "shortcuts.export-shapes")
(tr "shortcuts.find")
(tr "shortcuts.find-and-replace")
(tr "shortcuts.fit-all")
(tr "shortcuts.flip-horizontal")
(tr "shortcuts.flip-vertical")
@@ -160,6 +162,7 @@
(tr "shortcuts.open-viewer")
(tr "shortcuts.open-workspace")
(tr "shortcuts.paste")
(tr "shortcuts.paste-replace")
(tr "shortcuts.prev-frame")
(tr "shortcuts.redo")
(tr "shortcuts.rename")

View File

@@ -4662,6 +4662,14 @@ msgstr "Cancel"
msgid "shortcuts.export-shapes"
msgstr "Export shapes"
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs
msgid "shortcuts.find"
msgstr "Find"
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs
msgid "shortcuts.find-and-replace"
msgstr "Find and replace"
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:114
msgid "shortcuts.fit-all"
msgstr "Zoom to fit all"
@@ -4866,6 +4874,10 @@ msgstr " or "
msgid "shortcuts.paste"
msgstr "Paste"
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs
msgid "shortcuts.paste-replace"
msgstr "Paste and replace"
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:111
#, unused
msgid "shortcuts.paste-props"
@@ -8148,7 +8160,7 @@ msgstr "Masks"
#: src/app/main/ui/workspace/sidebar/layers.cljs:293
msgid "workspace.sidebar.layers.search"
msgstr "Search layers"
msgstr "Find…"
#: src/app/main/ui/workspace/sidebar/layers.cljs:316, src/app/main/ui/workspace/sidebar/layers.cljs:410
msgid "workspace.sidebar.layers.shapes"
@@ -8180,11 +8192,11 @@ msgstr "No matches"
#: src/app/main/ui/workspace/sidebar/layers.cljs
msgid "workspace.sidebar.layers.search-scope-layers"
msgstr "Search layers"
msgstr "Layer names"
#: src/app/main/ui/workspace/sidebar/layers.cljs
msgid "workspace.sidebar.layers.search-scope-canvas"
msgstr "Search on canvas"
msgstr "Text content"
#: 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"