Belén Albeza cb1118f904
🎉 Preserve Penpot's internal formatting when pasting (v3) (#12137)
* 🎉 Copy v3 editor text with its styles in a payload

Copy and cut in the v3 text editor put the styled selection in a
hidden span of text/html. Add its codec and the clean-up for pasting
into another file or team.

Refs #12047

AI-assisted-by: claude-opus-5-5

* 🎉 Paste text copied in Penpot at the v3 editor caret

Text copied in Penpot keeps its styles when pasted at the caret. The
first paragraph keeps the target's attrs; later ones bring their own.
The context menu paste now shares the keyboard paste path.

Refs #12047

AI-assisted-by: claude-opus-5-5

* 🎉 Paste text copied in Penpot on the canvas with its styles

Refs #12047

AI-assisted-by: claude-opus-5-5

* 🎉 Paste copied text shapes at the v3 editor caret with styles

Copied shapes now record their team. Shapes that are not texts paste
nothing into the editor.

Refs #12047

AI-assisted-by: claude-opus-5-5

* ✨ Replace custom fonts in shapes pasted across teams

Refs #12047

AI-assisted-by: claude-opus-5-5
2026-10-07 18:20:03 +02:00

193 lines
9.5 KiB
Clojure

;; 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 SUBSIDIARY SL
(ns frontend-tests.render-wasm.text-paste-test
"Restyling pasted text. Test content already holds the pasted text in the
style of the span it went into, as after `text-editor-sync-content`."
(:require
[app.render-wasm.text-paste :as text-paste]
[cljs.test :as t :include-macros true]))
(def ^:private regular
{:font-id "sourcesanspro" :font-variant-id "regular"
:font-weight "400" :font-style "normal"})
(defn- span
([text] (span text {}))
([text attrs] (merge regular attrs {:text text})))
(defn- content [& paragraphs]
{:type "root"
:children [{:type "paragraph-set"
:children (mapv (fn [spans] {:type "paragraph" :children spans})
paragraphs)}]})
(defn- spans-of [content]
(mapv :children (-> content :children first :children)))
(defn- fragment-paragraph [& runs]
{:attrs {} :children (vec runs)})
(defn- run
([text] (run text {}))
([text attrs] {:text text :attrs attrs}))
(def ^:private bold-variant
{:font-weight "700" :font-style "normal" :font-variant-id "bold"})
(t/deftest fragment->text
(t/testing "paragraphs are joined by newlines"
(t/is (= "ab\n\nc"
(text-paste/fragment->text
[(fragment-paragraph (run "a") (run "b"))
(fragment-paragraph)
(fragment-paragraph (run "c"))])))))
(t/deftest bold-run-resolves-to-a-font-variant
(t/testing "a bold run inside a paragraph gets the bold variant of the span's font"
(let [pasted (content [(span "Hello big world")])
fragment [(fragment-paragraph (run "big " {:font-weight "700"}))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 6})]
(t/is (= [[(span "Hello ") (span "big " bold-variant) (span "world")]]
(spans-of result))))))
(t/deftest italic-run-keeps-the-span-weight
(t/testing "italic over a bold span picks the bold italic variant"
(let [pasted (content [(span "x" bold-variant)])
fragment [(fragment-paragraph (run "x" {:font-style "italic"}))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})]
(t/is (= [[(span "x" {:font-weight "700" :font-style "italic"
:font-variant-id "bolditalic"})]]
(spans-of result))))))
(t/deftest html-emphasis-replaces-the-caret-emphasis
(let [no-emphasis {:font-weight "400" :font-style "normal"
:text-decoration "none" :text-transform "none"}]
(t/testing "plain source text pasted into bold, underlined text comes out plain"
(let [pasted (content [(span "aXb" (assoc bold-variant :text-decoration "underline"))])
fragment [(fragment-paragraph (run "X" no-emphasis))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 1})]
(t/is (= [[(span "a" (assoc bold-variant :text-decoration "underline"))
(span "X" {:text-decoration "none" :text-transform "none"})
(span "b" (assoc bold-variant :text-decoration "underline"))]]
(spans-of result)))))
(t/testing "not bold leaves a light weight as it is"
(let [light {:font-weight "300" :font-style "normal" :font-variant-id "300"}
pasted (content [(span "X" light)])
fragment [(fragment-paragraph (run "X" no-emphasis))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})]
(t/is (= [[(span "X" (assoc light :text-decoration "none" :text-transform "none"))]]
(spans-of result)))))))
(t/deftest unknown-font-keeps-its-weight
(t/testing "a font we have no data for cannot resolve a variant, so it stays"
(let [unknown (span "x" {:font-id "missing-font"})
fragment [(fragment-paragraph (run "x" {:font-weight "700"}))]
result (text-paste/apply-fragment-styles (content [unknown]) fragment {:para 0 :offset 0})]
(t/is (= [[unknown]] (spans-of result))))))
(t/deftest typography-link
(let [linked {:typography-ref-id "typo-id" :typography-ref-file "file-id"}]
(t/testing "a span changed by the paste leaves its typography"
(let [fragment [(fragment-paragraph (run "x" {:text-decoration "underline"}))]
result (text-paste/apply-fragment-styles
(content [(span "x" linked)]) fragment {:para 0 :offset 0})]
(t/is (= [[(span "x" {:text-decoration "underline"})]]
(spans-of result)))))
(t/testing "a span the paste leaves as it was keeps its typography"
(let [already (span "x" (merge bold-variant linked))
fragment [(fragment-paragraph (run "x" {:font-weight "700"}))]
result (text-paste/apply-fragment-styles (content [already]) fragment {:para 0 :offset 0})]
(t/is (= [[already]] (spans-of result)))))))
(t/deftest ranges-across-paragraphs
(t/testing "runs after the first paragraph start at offset 0 of their paragraph"
(let [pasted (content [(span "abX")] [(span "Ycd")])
fragment [(fragment-paragraph (run "X" {:font-weight "700"}))
(fragment-paragraph (run "Y" {:font-weight "700"}))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 2})]
(t/is (= [[(span "ab") (span "X" bold-variant)]
[(span "Y" bold-variant) (span "cd")]]
(spans-of result))))))
(t/deftest ranges-count-utf16-units
(t/testing "an emoji in the fragment moves later runs by its two UTF-16 units"
(let [pasted (content [(span "😀b")])
fragment [(fragment-paragraph (run "😀") (run "b" {:font-weight "700"}))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})]
(t/is (= [[(span "😀") (span "b" bold-variant)]]
(spans-of result)))))
(t/testing "a paste after an emoji starts at the UTF-16 offset WASM reports"
(let [pasted (content [(span "😀Xb")])
fragment [(fragment-paragraph (run "X" {:font-weight "700"}))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 2})]
(t/is (= [[(span "😀") (span "X" bold-variant) (span "b")]]
(spans-of result))))))
(t/deftest fragment->content
(t/testing "runs become spans over the base style, and empty paragraphs keep one empty span"
(let [result (text-paste/fragment->content
[(fragment-paragraph (run "Hello ") (run "bold" {:font-weight "700"}))
(fragment-paragraph)]
regular)]
(t/is (= [[(span "Hello ") (span "bold" bold-variant)]
[(span "")]]
(spans-of result)))))
(t/testing "paragraphs carry the base style"
(let [result (text-paste/fragment->content [(fragment-paragraph (run "x"))] regular)]
(t/is (= (assoc regular :type "paragraph")
(dissoc (-> result :children first :children first) :children))))))
;; --- Text copied in Penpot
(def ^:private brand
{:font-id "gfont-roboto" :font-family "Roboto" :font-variant-id "700italic"
:font-weight "700" :font-style "italic"
:fills [{:fill-color "#ff0000" :fill-opacity 1}]})
(t/deftest content->fragment
(t/testing "paragraphs and spans keep their whole style, and empty spans are dropped"
(t/is (= [{:attrs {:text-align "center"}
:children [{:text "x" :attrs brand}]}
{:attrs {:text-align "right"}
:children []}]
(text-paste/content->fragment
{:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph" :text-align "center"
:children [(assoc brand :text "x") (assoc brand :text "")]}
{:type "paragraph" :text-align "right"
:children [(assoc brand :text "")]}]}]})))))
(t/deftest penpot-styles-replace-the-caret-style
(t/testing "pasted runs take their whole style, dropping the target's typography"
(let [pasted (content [(span "AxyB" {:typography-ref-id "t" :typography-ref-file "f"})])
fragment [(fragment-paragraph (run "x" brand) (run "y" regular))]
result (text-paste/apply-content-styles pasted fragment {:para 0 :offset 1})]
(t/is (= [[(span "A" {:typography-ref-id "t" :typography-ref-file "f"})
(assoc brand :text "x")
(span "y")
(span "B" {:typography-ref-id "t" :typography-ref-file "f"})]]
(spans-of result)))))
(t/testing "the first paragraph keeps its style; later ones bring theirs"
(let [pasted {:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph" :text-align "left" :children [(span "Ax")]}
{:type "paragraph" :text-align "left" :children [(span "yB")]}]}]}
fragment [{:attrs {:text-align "center"} :children [(run "x" brand)]}
{:attrs {:text-align "right"} :children [(run "y" brand)]}]
result (text-paste/apply-content-styles pasted fragment {:para 0 :offset 1})]
(t/is (= ["left" "right"]
(map :text-align (-> result :children first :children))))
(t/is (= [[(span "A") (assoc brand :text "x")]
[(assoc brand :text "y") (span "B")]]
(spans-of result))))))