From 5bbb2c5cffb48dc346335aec32a03a2f301f2396 Mon Sep 17 00:00:00 2001 From: Renzo <170978465+RenzoMXD@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:46:38 +0200 Subject: [PATCH] :bug: Fix Copy as SVG for multi-shape selection (#838) (#9066) Signed-off-by: RenzoMXD <170978465+RenzoMXD@users.noreply.github.com> --- CHANGES.md | 1 + .../app/main/data/workspace/clipboard.cljs | 4 +- frontend/src/app/main/render.cljs | 40 ++++++++++++ frontend/src/app/util/clipboard.cljs | 19 ++++++ .../src/app/util/code_gen/markup_svg.cljs | 26 +++++--- .../test/frontend_tests/copy_as_svg_test.cljs | 61 +++++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 7 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 frontend/test/frontend_tests/copy_as_svg_test.cljs diff --git a/CHANGES.md b/CHANGES.md index 794b299461..8cdf1ba7d0 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -50,6 +50,7 @@ ### :bug: Bugs fixed +- Fix Copy as SVG: emit a single valid SVG document when multiple shapes are selected, and publish `image/svg+xml` to the clipboard so the paste target works in Inkscape and other SVG-native tools [Github #838](https://github.com/penpot/penpot/issues/838) - Reset profile submenu state when the account menu closes (by @eureka0928) [Github #8947](https://github.com/penpot/penpot/issues/8947) - Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582) - Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526) diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 3017d94b2f..72f94ecdc4 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -358,7 +358,9 @@ shapes (mapv maybe-translate selected) svg-formatted (svg/generate-formatted-markup objects shapes)] - (clipboard/to-clipboard svg-formatted))))) + (clipboard/to-clipboard-multi + {"image/svg+xml" svg-formatted + "text/plain" svg-formatted}))))) (defn copy-selected-css [] diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index d60366592c..a53971c452 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -484,6 +484,46 @@ [:& ff/fontfaces-style {:fonts fonts}] [:& shape-wrapper {:shape object}]]]])) +(mf/defc objects-svg + {::mf/wrap [mf/memo]} + [{:keys [objects object-ids embed] :or {embed false} :as props}] + (let [shapes + (->> object-ids + (keep #(get objects %)) + (mapv (fn [object] + (cond-> object + (:hide-fill-on-export object) + (assoc :fills []))))) + + bounds + (->> shapes + (map #(gsb/get-object-bounds objects % {:ignore-margin? false})) + (grc/join-rects)) + + {:keys [width height]} bounds + vbox (format-viewbox bounds) + fonts (->> shapes + (mapcat #(ff/shape->fonts % objects)) + (distinct)) + + shape-wrapper + (mf/with-memo [objects] + (shape-wrapper-factory objects))] + + [:& (mf/provider export/include-metadata-ctx) {:value false} + [:& (mf/provider embed/context) {:value embed} + [:svg {:view-box vbox + :width (ust/format-precision width viewbox-decimal-precision) + :height (ust/format-precision height viewbox-decimal-precision) + :version "1.1" + :xmlns "http://www.w3.org/2000/svg" + :xmlnsXlink "http://www.w3.org/1999/xlink" + :style {:-webkit-print-color-adjust :exact} + :fill "none"} + [:& ff/fontfaces-style {:fonts fonts}] + (for [shape shapes] + [:& shape-wrapper {:key (dm/str (:id shape)) :shape shape}])]]])) + (defn render-to-canvas [objects canvas bounds scale object-id on-render] (let [width (.-width canvas) diff --git a/frontend/src/app/util/clipboard.cljs b/frontend/src/app/util/clipboard.cljs index 5f18798847..d06aa5c22e 100644 --- a/frontend/src/app/util/clipboard.cljs +++ b/frontend/src/app/util/clipboard.cljs @@ -88,3 +88,22 @@ (let [clipboard (unchecked-get js/navigator "clipboard") data (create-clipboard-item mimetype promise)] (.write ^js clipboard #js [data]))) + +(defn to-clipboard-multi + "Write multiple MIME representations as a single ClipboardItem. + `items` is a map of mime-type (string) -> string payload. + Falls back to `writeText` when the async Clipboard API is unavailable." + [items] + (let [clipboard (unchecked-get js/navigator "clipboard")] + (if (and clipboard (unchecked-get clipboard "write")) + (let [obj (reduce-kv + (fn [acc mime payload] + (let [blob (js/Blob. #js [payload] #js {:type mime})] + (unchecked-set acc mime (js/Promise.resolve blob)) + acc)) + #js {} items) + item (js/ClipboardItem. obj)] + (.write ^js clipboard #js [item])) + (when-let [text (or (get items "text/plain") + (first (vals items)))] + (.writeText ^js clipboard text))))) diff --git a/frontend/src/app/util/code_gen/markup_svg.cljs b/frontend/src/app/util/code_gen/markup_svg.cljs index 65044af6d7..6ab4ad0799 100644 --- a/frontend/src/app/util/code_gen/markup_svg.cljs +++ b/frontend/src/app/util/code_gen/markup_svg.cljs @@ -9,10 +9,9 @@ ["react-dom/server" :as rds] [app.main.render :as render] [app.util.code-beautify :as cb] - [cuerdas.core :as str] [rumext.v2 :as mf])) -(defn generate-svg +(defn- generate-single-svg [objects shape] (rds/renderToStaticMarkup (mf/element @@ -20,13 +19,26 @@ #js {:objects objects :object-id (-> shape :id)}))) +(defn- generate-multi-svg + [objects shapes] + (rds/renderToStaticMarkup + (mf/element + render/objects-svg + #js {:objects objects + :object-ids (mapv :id shapes)}))) + +(defn generate-svg + [objects shape] + (generate-single-svg objects shape)) + (defn generate-markup [objects shapes] - (->> shapes - (map #(generate-svg objects %)) - (str/join "\n"))) + (case (count shapes) + 0 "" + 1 (generate-single-svg objects (first shapes)) + (generate-multi-svg objects shapes))) (defn generate-formatted-markup [objects shapes] - (let [markup (generate-markup objects shapes)] - (cb/format-code markup "svg"))) + (-> (generate-markup objects shapes) + (cb/format-code "svg"))) diff --git a/frontend/test/frontend_tests/copy_as_svg_test.cljs b/frontend/test/frontend_tests/copy_as_svg_test.cljs new file mode 100644 index 0000000000..c2aee4a298 --- /dev/null +++ b/frontend/test/frontend_tests/copy_as_svg_test.cljs @@ -0,0 +1,61 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.copy-as-svg-test + "Regression tests for the Copy as SVG action (issue #838). + + The bug: when multiple shapes were selected, `generate-markup` emitted + several sibling `` roots concatenated with newlines. External SVG + parsers (Inkscape, browsers) only read the first root, so multi-shape + selection appeared to copy only one shape. The fix wraps 2+ shapes in a + single `` root with a combined viewBox." + (:require + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.util.code-gen.markup-svg :as svg] + [cljs.test :refer [deftest is testing] :include-macros true])) + +(defn- setup-shapes + "Build a file with `n` sample rectangles on the current page. + Returns a map with `:objects` and `:shapes` keys, mirroring the inputs + that `copy-selected-svg` feeds into `generate-markup`." + [labels] + (let [file (reduce (fn [f label] + (cths/add-sample-shape f label)) + (cthf/sample-file :file1 :page-label :page1) + labels) + page (cthf/current-page file) + objects (:objects page) + shapes (mapv #(get objects (cthi/id %)) labels)] + {:objects objects :shapes shapes})) + +(defn- count-matches + [re s] + (count (re-seq re s))) + +(deftest empty-selection-yields-empty-string + (is (= "" (svg/generate-markup {} [])))) + +(deftest single-shape-produces-one-svg-root + (testing "Regression guard: the single-shape path stays unchanged" + (let [{:keys [objects shapes]} (setup-shapes [:rect-1]) + markup (svg/generate-markup objects shapes)] + (is (string? markup)) + (is (pos? (count markup))) + (is (= 1 (count-matches #" root")))) + +(deftest multi-shape-produces-single-svg-root + (testing "Fix for #838: multiple shapes share one outer " + (let [{:keys [objects shapes]} (setup-shapes [:rect-1 :rect-2 :rect-3]) + markup (svg/generate-markup objects shapes)] + (is (string? markup)) + (is (pos? (count markup))) + (is (= 1 (count-matches #" roots") + (is (= 1 (count-matches #"" markup)) + "multi-select must NOT emit multiple closing tags")))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index b7b53f8fbb..174ef34056 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -2,6 +2,7 @@ (:require [cljs.test :as t] [frontend-tests.basic-shapes-test] + [frontend-tests.copy-as-svg-test] [frontend-tests.data.repo-test] [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] @@ -44,6 +45,7 @@ [] (t/run-tests 'frontend-tests.basic-shapes-test + 'frontend-tests.copy-as-svg-test 'frontend-tests.data.repo-test 'frontend-tests.errors-test 'frontend-tests.main-errors-test