From 42ebee88d6bc83fe6ad45dae5e0a0158751d709a Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka0928@users.noreply.github.com> Date: Mon, 20 Apr 2026 05:03:50 -0400 Subject: [PATCH] :sparkles: 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 --- CHANGES.md | 1 + .../app/main/data/workspace/clipboard.cljs | 56 +++++++++++++------ .../app/main/data/workspace/shortcuts.cljs | 5 ++ 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f9b9322ff3..b695345f6d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -33,6 +33,7 @@ - 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) - 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) - 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) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 4c3e60f7d4..3017d94b2f 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -18,6 +18,7 @@ [app.common.geom.shapes :as gsh] [app.common.geom.shapes.grid-layout :as gslg] [app.common.logic.libraries :as cll] + [app.common.logic.shapes :as cls] [app.common.schema :as sm] [app.common.transit :as t] [app.common.types.component :as ctc] @@ -260,7 +261,7 @@ :allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")}) (defn- create-paste-from-blob - [in-viewport?] + [in-viewport? replace?] (fn [blob] (let [type (.-type blob)] (cond @@ -281,7 +282,9 @@ (rx/filter map?) (rx/map (fn [pdata] - (assoc pdata :in-viewport in-viewport?))) + (-> pdata + (assoc :in-viewport in-viewport?) + (assoc :replace replace?)))) (rx/mapcat (fn [pdata] (case (:type pdata) @@ -293,8 +296,6 @@ (->> (rx/from (.text blob)) (rx/map paste-text)))))) -(def default-paste-from-blob (create-paste-from-blob false)) - (defn- clipboard-permission-error? "Check if the given error is a clipboard permission error (NotAllowedError DOMException)." @@ -313,14 +314,15 @@ (defn paste-from-clipboard "Perform a `paste` operation using the Clipboard API." - [] - (ptk/reify ::paste-from-clipboard - ptk/WatchEvent - (watch [_ _ _] - (->> (clipboard/from-navigator default-options) - (rx/mapcat default-paste-from-blob) - (rx/take 1) - (rx/catch on-clipboard-permission-error))))) + ([] (paste-from-clipboard nil)) + ([{:keys [replace?]}] + (ptk/reify ::paste-from-clipboard + ptk/WatchEvent + (watch [_ _ _] + (->> (clipboard/from-navigator default-options) + (rx/mapcat (create-paste-from-blob false (boolean replace?))) + (rx/take 1) + (rx/catch on-clipboard-permission-error)))))) (defn paste-from-event "Perform a `paste` operation from user emmited event." @@ -337,7 +339,7 @@ (if is-editing? (rx/empty) (->> (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 [] @@ -722,7 +724,7 @@ (update change :obj process-rchange-shape media-idx) 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) selected-objs (map (d/getf pobjects) selected) first-selected-obj (first selected-objs) @@ -736,9 +738,20 @@ tree-root (get-tree-root-shapes pobjects) only-one-root-shape? (and (< 1 (count pobjects)) - (= 1 (count tree-root)))] + (= 1 (count tree-root))) + replaced (some->> replace-id (get page-objects))] (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 (and (selected-frame? state) (or (any-same-frame-from-selected? state (keys pobjects)) @@ -854,10 +867,17 @@ 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 [candidate-parent-id delta - index] (calculate-paste-position state objects selected position) + index] (calculate-paste-position state objects selected position replace-id) page-objects (:objects page) @@ -899,6 +919,10 @@ (map :id) (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) children-after (-> (pcb/get-objects changes) diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 3c147d7a7f..a89662e8d3 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -104,6 +104,11 @@ :subsections [:edit] :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")) :command (ds/c-mod "alt+c") :subsections [:edit]