mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 22:10:34 +00:00
* 🎉 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
193 lines
9.5 KiB
Clojure
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))))))
|