Add paste to replace (Cmd+Shift+V) (#9033)

Paste clipboard contents in place of the currently selected shape,
inheriting its position, parent, and z-index. The replaced shape
is deleted in the same transaction for a single undo step.

Signed-off-by: eureka0928 <meobius123@gmail.com>
This commit is contained in:
Dream 2026-04-20 05:03:50 -04:00 committed by GitHub
parent f0c68fb826
commit 42ebee88d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 46 additions and 16 deletions

View File

@ -33,6 +33,7 @@
- Make links in comments clickable (by @eureka0928) [Github #1602](https://github.com/penpot/penpot/issues/1602) - Make links in comments clickable (by @eureka0928) [Github #1602](https://github.com/penpot/penpot/issues/1602)
- Add visibility toggle for strokes (by @eureka0928) [Github #7438](https://github.com/penpot/penpot/issues/7438) - Add visibility toggle for strokes (by @eureka0928) [Github #7438](https://github.com/penpot/penpot/issues/7438)
- Sort asset library subfolders alphabetically at every nesting level (by @eureka0928) [Github #2572](https://github.com/penpot/penpot/issues/2572) - Sort asset library subfolders alphabetically at every nesting level (by @eureka0928) [Github #2572](https://github.com/penpot/penpot/issues/2572)
- Add Paste to replace (Cmd+Shift+V) to replace the selected shape with clipboard contents (by @eureka0928) [Github #4240](https://github.com/penpot/penpot/issues/4240)
- Differentiate incoming and outgoing interaction link colors (by @claytonlin1110) [Github #7794](https://github.com/penpot/penpot/issues/7794) - Differentiate incoming and outgoing interaction link colors (by @claytonlin1110) [Github #7794](https://github.com/penpot/penpot/issues/7794)
- Add guide locking and fix locked elements not selectable in viewer (by @Dexterity104) [Github #8358](https://github.com/penpot/penpot/issues/8358) - Add guide locking and fix locked elements not selectable in viewer (by @Dexterity104) [Github #8358](https://github.com/penpot/penpot/issues/8358)
- Apply styles to selection (by @AzazelN28) [Taiga #13647](https://tree.taiga.io/project/penpot/task/13647) - Apply styles to selection (by @AzazelN28) [Taiga #13647](https://tree.taiga.io/project/penpot/task/13647)

View File

@ -18,6 +18,7 @@
[app.common.geom.shapes :as gsh] [app.common.geom.shapes :as gsh]
[app.common.geom.shapes.grid-layout :as gslg] [app.common.geom.shapes.grid-layout :as gslg]
[app.common.logic.libraries :as cll] [app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.schema :as sm] [app.common.schema :as sm]
[app.common.transit :as t] [app.common.transit :as t]
[app.common.types.component :as ctc] [app.common.types.component :as ctc]
@ -260,7 +261,7 @@
:allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")}) :allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")})
(defn- create-paste-from-blob (defn- create-paste-from-blob
[in-viewport?] [in-viewport? replace?]
(fn [blob] (fn [blob]
(let [type (.-type blob)] (let [type (.-type blob)]
(cond (cond
@ -281,7 +282,9 @@
(rx/filter map?) (rx/filter map?)
(rx/map (rx/map
(fn [pdata] (fn [pdata]
(assoc pdata :in-viewport in-viewport?))) (-> pdata
(assoc :in-viewport in-viewport?)
(assoc :replace replace?))))
(rx/mapcat (rx/mapcat
(fn [pdata] (fn [pdata]
(case (:type pdata) (case (:type pdata)
@ -293,8 +296,6 @@
(->> (rx/from (.text blob)) (->> (rx/from (.text blob))
(rx/map paste-text)))))) (rx/map paste-text))))))
(def default-paste-from-blob (create-paste-from-blob false))
(defn- clipboard-permission-error? (defn- clipboard-permission-error?
"Check if the given error is a clipboard permission error "Check if the given error is a clipboard permission error
(NotAllowedError DOMException)." (NotAllowedError DOMException)."
@ -313,14 +314,15 @@
(defn paste-from-clipboard (defn paste-from-clipboard
"Perform a `paste` operation using the Clipboard API." "Perform a `paste` operation using the Clipboard API."
[] ([] (paste-from-clipboard nil))
(ptk/reify ::paste-from-clipboard ([{:keys [replace?]}]
ptk/WatchEvent (ptk/reify ::paste-from-clipboard
(watch [_ _ _] ptk/WatchEvent
(->> (clipboard/from-navigator default-options) (watch [_ _ _]
(rx/mapcat default-paste-from-blob) (->> (clipboard/from-navigator default-options)
(rx/take 1) (rx/mapcat (create-paste-from-blob false (boolean replace?)))
(rx/catch on-clipboard-permission-error))))) (rx/take 1)
(rx/catch on-clipboard-permission-error))))))
(defn paste-from-event (defn paste-from-event
"Perform a `paste` operation from user emmited event." "Perform a `paste` operation from user emmited event."
@ -337,7 +339,7 @@
(if is-editing? (if is-editing?
(rx/empty) (rx/empty)
(->> (clipboard/from-synthetic-clipboard-event event default-options) (->> (clipboard/from-synthetic-clipboard-event event default-options)
(rx/mapcat (create-paste-from-blob in-viewport?)))))))) (rx/mapcat (create-paste-from-blob in-viewport? false))))))))
(defn copy-selected-svg (defn copy-selected-svg
[] []
@ -722,7 +724,7 @@
(update change :obj process-rchange-shape media-idx) (update change :obj process-rchange-shape media-idx)
change)) change))
(calculate-paste-position [state pobjects selected position] (calculate-paste-position [state pobjects selected position replace-id]
(let [page-objects (dsh/lookup-page-objects state) (let [page-objects (dsh/lookup-page-objects state)
selected-objs (map (d/getf pobjects) selected) selected-objs (map (d/getf pobjects) selected)
first-selected-obj (first selected-objs) first-selected-obj (first selected-objs)
@ -736,9 +738,20 @@
tree-root (get-tree-root-shapes pobjects) tree-root (get-tree-root-shapes pobjects)
only-one-root-shape? (and only-one-root-shape? (and
(< 1 (count pobjects)) (< 1 (count pobjects))
(= 1 (count tree-root)))] (= 1 (count tree-root)))
replaced (some->> replace-id (get page-objects))]
(cond (cond
;; Paste in place: center pasted content on the replaced shape and
;; reparent to its container. The replaced shape is deleted below
;; so the new content takes its z-index slot.
(some? replaced)
(let [delta (gpt/subtract (gsh/shape->center replaced)
(grc/rect->center wrapper))
parent-id (:parent-id replaced)
target-index (cfh/get-position-on-parent page-objects replace-id)]
[parent-id delta target-index])
;; Paste next to selected frame, if selected is itself or of the same size as the copied ;; Paste next to selected frame, if selected is itself or of the same size as the copied
(and (selected-frame? state) (and (selected-frame? state)
(or (any-same-frame-from-selected? state (keys pobjects)) (or (any-same-frame-from-selected? state (keys pobjects))
@ -854,10 +867,17 @@
position (deref ms/mouse-position) position (deref ms/mouse-position)
;; Replace mode is only valid with a single selected shape.
;; In that case we drop the pasted content at its position and
;; delete it in the same transaction.
page-selected (dsh/lookup-selected state)
replace-id (when (and (:replace pdata) (= 1 (count page-selected)))
(first page-selected))
;; Calculate position for the pasted elements ;; Calculate position for the pasted elements
[candidate-parent-id [candidate-parent-id
delta delta
index] (calculate-paste-position state objects selected position) index] (calculate-paste-position state objects selected position replace-id)
page-objects (:objects page) page-objects (:objects page)
@ -899,6 +919,10 @@
(map :id) (map :id)
(pcb/resize-parents changes)) (pcb/resize-parents changes))
changes (if (some? replace-id)
(second (cls/generate-delete-shapes changes #{replace-id} {}))
changes)
orig-shapes (map (d/getf all-objects) selected) orig-shapes (map (d/getf all-objects) selected)
children-after (-> (pcb/get-objects changes) children-after (-> (pcb/get-objects changes)

View File

@ -104,6 +104,11 @@
:subsections [:edit] :subsections [:edit]
:fn (constantly nil)} :fn (constantly nil)}
:paste-replace {:tooltip (ds/meta (ds/shift "V"))
:command (ds/c-mod "shift+v")
:subsections [:edit]
:fn #(emit-when-no-readonly (dw/paste-from-clipboard {:replace? true}))}
:copy-props {:tooltip (ds/meta (ds/alt "c")) :copy-props {:tooltip (ds/meta (ds/alt "c"))
:command (ds/c-mod "alt+c") :command (ds/c-mod "alt+c")
:subsections [:edit] :subsections [:edit]