🎉 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
This commit is contained in:
Belén Albeza 2026-10-07 18:20:03 +02:00 committed by GitHub
parent f252439d84
commit cb1118f904
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 638 additions and 172 deletions

View File

@ -213,6 +213,7 @@
:features features
:version version
:file-id file-id
:team-id (:current-team-id state)
:selected selected
:objects {}
:images #{}}
@ -578,6 +579,7 @@
[:features ::sm/set-of-strings]
[:version :int]
[:file-id ::sm/uuid]
[:team-id {:optional true} ::sm/uuid]
[:selected ::sm/set-of-uuid]
[:objects
[:map-of ::sm/uuid :map]]
@ -883,7 +885,7 @@
(assoc change :index (get map-ids (:old-id change)))
change)))
(process-shape [valid-file-ids frame-id parent-id shape]
(process-shape [valid-file-ids other-team? frame-id parent-id shape]
(cond-> shape
:always
(assoc :frame-id frame-id :parent-id parent-id)
@ -894,7 +896,11 @@
(assoc :shapes [])
(cfh/text-shape? shape)
(ctt/remove-external-typographies valid-file-ids)))]
(ctt/remove-external-typographies valid-file-ids)
;; Custom font ids differ between teams.
(and other-team? (cfh/text-shape? shape))
(update :content text-clipboard/replace-custom-fonts)))]
(ptk/reify ::paste-shapes
ptk/WatchEvent
@ -947,7 +953,11 @@
valid-file-ids (conj (set (keys libraries)) file-id)
objects (update-vals objects (partial process-shape valid-file-ids frame-id parent-id))
;; Shapes copied before the team was recorded count as same team.
other-team? (and (some? (:team-id pdata))
(not= (:team-id pdata) (:current-team-id state)))
objects (update-vals objects (partial process-shape valid-file-ids other-team? frame-id parent-id))
all-objects (merge page-objects objects)
@ -1067,10 +1077,13 @@
(watch [_ state _]
(let [[text content]
(if (v3-html-paste? state)
(when-let [fragment (some-> (text-clipboard/html->fragment html)
(text-clipboard/without-overrides))]
[(text-paste/fragment->text fragment)
(text-paste/fragment->content fragment (txt/get-default-text-attrs))])
(if-let [payload (text-clipboard/html->payload html)]
(let [content (dwtxt/payload-content state payload)]
[(txt/content->text content) content])
(when-let [fragment (some-> (text-clipboard/html->fragment html)
(text-clipboard/without-overrides))]
[(text-paste/fragment->text fragment)
(text-paste/fragment->content fragment (txt/get-default-text-attrs))]))
(let [style (deref refs/workspace-clipboard-style)
root (dwtxt/create-root-from-html html style (features/active-feature? @st/state "text-editor/v2-html-paste"))]
[(.-textContent root) (tc/dom->cljs root)]))]

View File

@ -10,12 +10,14 @@
[app.common.attrs :as attrs]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh]
[app.common.math :as mth]
[app.common.transit :as t]
[app.common.types.fills :as types.fills]
[app.common.types.modifiers :as ctm]
[app.common.types.shape.layout :as ctl]
@ -42,8 +44,10 @@
[app.render-wasm.api :as wasm.api]
[app.render-wasm.api.fonts :as wasm.fonts]
[app.render-wasm.text-editor :as wasm.text-editor]
[app.render-wasm.text-paste :as text-paste]
[app.util.clipboard :as clipboard]
[app.util.text-editor :as ted]
[app.util.text.clipboard :as text-clipboard]
[app.util.text.content :as tc]
[app.util.text.content.styles :as styles]
[app.util.timers :as ts]
@ -1419,18 +1423,34 @@
(gsh/transform-shape (ctm/change-size shape width height))))))
{:undo-group (when new-shape? id)}))))))))
(defn- editor-content-update
"Event saving `content` from the WASM editor, renaming the shape after its text."
[shape-id content & {:keys [finalize?]}]
(let [text (txt/content->text content)
name (when (not= text "")
(txt/generate-shape-name text))]
(v2-update-text-shape-content shape-id content
:update-name? true
:name name
:finalize? finalize?)))
(defn v3-sync-editor-content
"Event pushing the WASM editor content back into the shape, or nil when there is
nothing to sync. Every text edit commits through it, menu or keystroke alike."
[& {:keys [finalize?]}]
(when-let [{:keys [shape-id content]} (wasm.text-editor/text-editor-sync-content)]
(let [text (txt/content->text content)
name (when (not= text "")
(txt/generate-shape-name text))]
(v2-update-text-shape-content shape-id content
:update-name? true
:name name
:finalize? finalize?))))
(editor-content-update shape-id content :finalize? finalize?)))
(defn v3-apply-pending-caret-styles
"Event saving an insertion made at `before` that consumed the pending caret style:
the inserted text gets that style as its own span. Nil when nothing changed."
[shape-id before]
(let [range (wasm.text-editor/typed-range before (wasm.text-editor/caret-position))]
;; Sync first so the cached content stays index-aligned with WASM.
(wasm.text-editor/text-editor-sync-content)
(if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)]
(editor-content-update shape-id content)
(v3-sync-editor-content))))
(defn- sync-editor-content-stream
"Stream of the sync event for `reason`, after asking WASM to repaint."
@ -1441,19 +1461,23 @@
(rx/of event)
(rx/empty))))
(defn- editor-selected-text
"Plain text of the current WASM editor selection, or nil when there is none."
[]
(defn editor-selection-clipboard-data
"Clipboard items for the WASM editor selection, or nil when nothing is selected.
The HTML also carries the styled content for pasting it in Penpot."
[state]
(when (and (wasm.text-editor/text-editor-has-focus?)
(wasm.text-editor/text-editor-has-selection?))
(let [text (wasm.text-editor/text-editor-export-selection)]
(when (seq text) text))))
(defn- write-selection-to-clipboard
"Write `text` as plain text and HTML; Windows apps often prefer CF_HTML."
[text]
(clipboard/to-clipboard-multi {"text/plain" text
"text/html" (clipboard/plain-text->html text)}))
(when (seq text)
(let [content (when (features/active-feature? state "text-editor-wasm/v1-html-paste")
(-> (wasm.text-editor/text-editor-get-active-shape-id)
(wasm.text-editor/selection-content)))
payload (when (some? content)
(text-clipboard/payload->html content
(:current-file-id state)
(:current-team-id state)))]
{"text/plain" text
"text/html" (clipboard/plain-text->html text payload)})))))
(defn- on-clipboard-error
[cause]
@ -1471,9 +1495,9 @@
[]
(ptk/reify ::v3-copy-selection
ptk/WatchEvent
(watch [_ _ _]
(if-let [text (editor-selected-text)]
(->> (rx/from (write-selection-to-clipboard text))
(watch [_ state _]
(if-let [data (editor-selection-clipboard-data state)]
(->> (rx/from (clipboard/to-clipboard-multi data))
(rx/ignore)
(rx/catch on-clipboard-error))
(rx/empty)))))
@ -1483,9 +1507,9 @@
[]
(ptk/reify ::v3-cut-selection
ptk/WatchEvent
(watch [_ _ _]
(if-let [text (editor-selected-text)]
(->> (rx/from (write-selection-to-clipboard text))
(watch [_ state _]
(if-let [data (editor-selection-clipboard-data state)]
(->> (rx/from (clipboard/to-clipboard-multi data))
(rx/mapcat (fn [_]
;; Delete only once the text is safely on the clipboard,
;; so a refused clipboard cannot lose the selection.
@ -1494,23 +1518,122 @@
(rx/catch on-clipboard-error))
(rx/empty)))))
;; Bigger HTML is pasted as plain text rather than walked.
(def ^:private max-paste-html-length 1000000)
(defn payload-content
"The content of a Penpot text `payload` without what the current file cannot reach.
A payload without team counts as coming from the current team."
[state {:keys [content file-id team-id]}]
(let [current-file-id (:current-file-id state)]
(text-clipboard/clean-content
content
{:valid-file-ids (conj (set (keys (dsh/lookup-libraries state))) current-file-id)
:same-file? (= file-id current-file-id)
:same-team? (or (nil? team-id) (= team-id (:current-team-id state)))})))
(defn- decode-copied-shapes
"The copied shapes data in clipboard `text`, or nil when it holds something else."
[text]
(when (str/starts-with? (str/trim (or text "")) "{")
(let [data (ex/ignoring (t/decode-str text))]
(when (and (map? data) (= :copied-shapes (:type data)))
data))))
(defn- copied-shapes->payload
"A text payload joining the content of the copied text shapes, top to bottom and
left to right; nil when none of the copied shapes is a text."
[{:keys [objects selected file-id team-id]}]
(let [texts (->> selected
(keep #(get objects %))
(filter cfh/text-shape?)
(sort-by (juxt :y :x)))]
(when (seq texts)
{:file-id file-id
:team-id team-id
:content (assoc-in (:content (first texts))
[:children 0 :children]
(into [] (mapcat #(-> % :content :children first :children)) texts))})))
(defn- penpot-paste
[state payload]
{:penpot? true
:fragment (text-paste/content->fragment (payload-content state payload))})
(defn- clipboard->paste
"What to paste for clipboard `html` and `text`, or nil: text and text shapes copied
in Penpot keep their styles; any other text takes the caret style."
[state html text]
(let [html-paste? (features/active-feature? state "text-editor-wasm/v1-html-paste")
payload (when html-paste? (text-clipboard/html->payload html))
shapes (when (and html-paste? (nil? payload)) (decode-copied-shapes text))]
(cond
(some? payload)
(penpot-paste state payload)
(some? shapes)
(some->> (copied-shapes->payload shapes) (penpot-paste state))
:else
(when-let [fragment (or (when (and html-paste? (< 0 (count html) max-paste-html-length))
(some-> (text-clipboard/html->fragment html)
(text-clipboard/without-overrides)))
(text-clipboard/text->fragment text))]
{:penpot? false
:fragment fragment}))))
(defn- insert-fragment
"Insert the text of `fragment` at the caret and restyle it: with its own styles
or overrides, else with the pending caret style. Returns the event saving it."
[{:keys [fragment penpot?]}]
(let [shape-id (wasm.text-editor/text-editor-get-active-shape-id)
start (wasm.text-editor/selection-start)
styled? (or penpot? (text-paste/styled? fragment))
pending? (some? (wasm.text-editor/get-pending-caret-styles shape-id))]
(when styled?
(wasm.text-editor/clear-pending-caret-styles!))
(wasm.text-editor/text-editor-insert-text (text-paste/fragment->text fragment))
(cond
(nil? start)
(v3-sync-editor-content)
styled?
(do
;; Sync first so the cached content stays index-aligned with WASM.
(wasm.text-editor/text-editor-sync-content)
(if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start penpot?)]
(editor-content-update shape-id content)
(v3-sync-editor-content)))
pending?
(v3-apply-pending-caret-styles shape-id start)
:else
(v3-sync-editor-content))))
(defn v3-paste
"Paste clipboard `html` and `text` at the caret, replacing the selection."
[html text]
(ptk/reify ::v3-paste
ptk/WatchEvent
(watch [_ state _]
(if-let [paste (when (wasm.text-editor/text-editor-has-focus?)
(clipboard->paste state html text))]
(let [event (insert-fragment paste)]
(wasm.api/request-render-preserving-target "text-paste")
(if (some? event) (rx/of event) (rx/empty)))
(rx/empty)))))
(defn v3-paste-text
"Insert the system clipboard text at the caret, replacing the selection."
"Paste the system clipboard at the caret, replacing the selection."
[]
(ptk/reify ::v3-paste-text
ptk/WatchEvent
(watch [_ _ _]
(if-not (wasm.text-editor/text-editor-has-focus?)
(rx/empty)
(->> (rx/from (clipboard/read-text))
(rx/mapcat (fn [text]
(if (seq text)
(do
;; Pasted text keeps the surrounding style.
(wasm.text-editor/clear-pending-caret-styles!)
(wasm.text-editor/text-editor-insert-text text)
(sync-editor-content-stream "text-paste"))
(rx/empty))))
(->> (rx/from (clipboard/read-html-and-text))
(rx/map (fn [{:keys [html text]}] (v3-paste html text)))
(rx/catch on-clipboard-error))))))
(defn v3-select-all

View File

@ -9,23 +9,18 @@
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.common.types.text :as txt]
[app.config :as cf]
[app.main.data.helpers :as dsh]
[app.main.data.workspace :as dw]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.css-cursors :as cur]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor :as text-editor]
[app.render-wasm.text-paste :as text-paste]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[app.util.text.clipboard :as text-clipboard]
[app.util.timers :as ts]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@ -63,97 +58,19 @@
"Home" "End" "PageUp" "PageDown"
"Enter" "Backspace" "Delete" "Escape" "Tab"})
(defn- caret-position
"Collapsed caret as {:para :offset} from the WASM selection, or nil."
[]
(when-let [{:keys [focus-para focus-offset]} (text-editor/text-editor-get-selection)]
{:para focus-para :offset focus-offset}))
(defn- typed-range
"Normalized range covering the text inserted between `before` and `after`, or nil."
[before after]
(when (and before after)
(if (or (< (:para before) (:para after))
(and (= (:para before) (:para after))
(<= (:offset before) (:offset after))))
{:start-para (:para before) :start-offset (:offset before)
:end-para (:para after) :end-offset (:offset after)}
{:start-para (:para after) :start-offset (:offset after)
:end-para (:para before) :end-offset (:offset before)})))
(defn- commit-restyled-content
"Save `content` restyled after an insertion, renaming the shape after its text."
[shape-id content]
(let [text (txt/content->text content)
name (when (not= text "") (txt/generate-shape-name text))]
(st/emit! (dwt/v2-update-text-shape-content
shape-id content
:update-name? true
:name name))))
(defn- sync-with-pending-caret-styles!
"Commit an insertion that consumed a pending caret style: sync the new text,
then restyle the just-typed `range` into its own span. `before` is the
"Commit an insertion that consumed a pending caret style. `before` is the
pre-insert caret."
[shape-id before]
(let [range (typed-range before (caret-position))]
;; Sync first so the cached content stays index-aligned with WASM.
(text-editor/text-editor-sync-content)
(if-let [{:keys [content]} (wasm.api/apply-pending-caret-styles! shape-id range)]
(commit-restyled-content shape-id content)
(sync-wasm-text-editor-content!))))
(when-let [event (dwt/v3-apply-pending-caret-styles shape-id before)]
(st/emit! event)))
;; Bigger HTML is pasted as plain text rather than walked.
(def ^:private max-paste-html-length 1000000)
(defn- clipboard->fragment
"The paste fragment for `data`: its HTML when allowed and it has text, else its
plain text. Either way it is unstyled, so it adopts the caret style."
[^js data html-paste?]
(let [html (when html-paste? (.getData data "text/html"))]
(or (when (< 0 (count html) max-paste-html-length)
(some-> (text-clipboard/html->fragment html)
(text-clipboard/without-overrides)))
(text-clipboard/text->fragment (.getData data "text/plain")))))
(defn- selection-start
"Start of the WASM selection as {:para :offset}: where pasted text goes."
[]
(when-let [{:keys [anchor-para anchor-offset focus-para focus-offset]}
(text-editor/text-editor-get-selection)]
(if (or (< anchor-para focus-para)
(and (= anchor-para focus-para) (<= anchor-offset focus-offset)))
{:para anchor-para :offset anchor-offset}
{:para focus-para :offset focus-offset})))
(defn- paste-fragment
"Insert the text of `fragment`, then restyle it with the fragment overrides or,
when it has none, with the pending caret style."
[fragment]
(let [shape-id (text-editor/text-editor-get-active-shape-id)
start (selection-start)
styled? (text-paste/styled? fragment)
pending? (some? (text-editor/get-pending-caret-styles shape-id))]
(when styled?
(text-editor/clear-pending-caret-styles!))
(text-editor/text-editor-insert-text (text-paste/fragment->text fragment))
(cond
(nil? start)
(sync-wasm-text-editor-content!)
styled?
(do
;; Sync first so the cached content stays index-aligned with WASM.
(text-editor/text-editor-sync-content)
(if-let [{:keys [content]} (wasm.api/apply-paste-styles shape-id fragment start)]
(commit-restyled-content shape-id content)
(sync-wasm-text-editor-content!)))
pending?
(sync-with-pending-caret-styles! shape-id start)
:else
(sync-wasm-text-editor-content!))))
(defn- write-clipboard-items
"Write `items` (mime type -> string) to the DataTransfer of a copy or cut event.
Windows apps prefer text/html, or they may paste the editor's empty `<br>`."
[^js data items]
(doseq [[mime value] items]
(.setData data mime value)))
(defn- reset-input-node
"Empties the contenteditable capture surface and restores a collapsed caret
@ -331,11 +248,9 @@
(mf/use-fn
(fn [^js event]
(dom/prevent-default event)
(when-let [fragment (some-> (.-clipboardData event)
(clipboard->fragment
(features/active-feature? @st/state "text-editor-wasm/v1-html-paste")))]
(paste-fragment fragment)
(wasm.api/request-render-preserving-target "text-paste"))
(when-let [data (.-clipboardData event)]
(st/emit! (dwt/v3-paste (.getData data "text/html")
(.getData data "text/plain"))))
(reset-input-node (mf/ref-val contenteditable-ref))))
on-copy
@ -343,30 +258,19 @@
(fn [^js event]
(when (text-editor/text-editor-has-focus?)
(dom/prevent-default event)
(when (text-editor/text-editor-has-selection?)
(let [text (or (text-editor/text-editor-export-selection) "")
html (clipboard/plain-text->html text)
data (.-clipboardData event)]
;; text/html matters on Windows: many apps prefer CF_HTML, and
;; without it they can pick up the empty contenteditable `<br>`.
(.setData data "text/plain" text)
(.setData data "text/html" html))))))
(when-let [items (dwt/editor-selection-clipboard-data @st/state)]
(write-clipboard-items (.-clipboardData event) items)))))
on-cut
(mf/use-fn
(fn [^js event]
(when (text-editor/text-editor-has-focus?)
(dom/prevent-default event)
(when (text-editor/text-editor-has-selection?)
(let [text (or (text-editor/text-editor-export-selection) "")
html (clipboard/plain-text->html text)
data (.-clipboardData event)]
(.setData data "text/plain" text)
(.setData data "text/html" html)
(when (seq text)
(text-editor/text-editor-delete-backward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render-preserving-target "text-cut"))))
(when-let [items (dwt/editor-selection-clipboard-data @st/state)]
(write-clipboard-items (.-clipboardData event) items)
(text-editor/text-editor-delete-backward)
(sync-wasm-text-editor-content!)
(wasm.api/request-render-preserving-target "text-cut"))
(reset-input-node (mf/ref-val contenteditable-ref)))))
on-key-down
@ -515,7 +419,7 @@
(let [shape-id (text-editor/text-editor-get-active-shape-id)
;; The inserted character adopts a pending caret style, if any.
pending-styles? (some? (text-editor/get-pending-caret-styles shape-id))
before (when pending-styles? (caret-position))]
before (when pending-styles? (text-editor/caret-position))]
(text-editor/text-editor-insert-text data)
(if pending-styles?
(sync-with-pending-caret-styles! shape-id before)

View File

@ -805,11 +805,13 @@
result)))
(defn apply-paste-styles
"Restyle the text just pasted at `start` with the overrides of `fragment`;
returns {:shape-id :content}, or nil when the shape has no cached content."
[shape-id fragment start]
"Restyle the text just pasted at `start` with `fragment`: its overrides, or its full
styles when copied in Penpot. Nil when the shape has no cached content."
[shape-id fragment start penpot?]
(when-let [content (text-editor/get-cached-content shape-id)]
(let [content (text-paste/apply-fragment-styles content fragment start)]
(let [content (if penpot?
(text-paste/apply-content-styles content fragment start)
(text-paste/apply-fragment-styles content fragment start))]
(wselect/use-shape shape-id)
(set-shape-text-content shape-id content)
(request-render "apply-paste-styles")

View File

@ -670,6 +670,26 @@
{:start-para focus-para :start-offset focus-offset
:end-para anchor-para :end-offset anchor-offset}))
(defn caret-position
"Collapsed caret as {:para :offset} from the WASM selection, or nil."
[]
(when-let [{:keys [focus-para focus-offset]} (text-editor-get-selection)]
{:para focus-para :offset focus-offset}))
(defn selection-start
"Start of the WASM selection as {:para :offset}: where inserted text goes."
[]
(when-let [selection (text-editor-get-selection)]
(let [{:keys [start-para start-offset]} (normalize-selection selection)]
{:para start-para :offset start-offset})))
(defn typed-range
"Normalized range covering the text inserted between `before` and `after`, or nil."
[before after]
(when (and before after)
(normalize-selection {:anchor-para (:para before) :anchor-offset (:offset before)
:focus-para (:para after) :focus-offset (:offset after)})))
(defn apply-attrs-to-paragraph
"Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans."
[para sel-start sel-end styles]
@ -772,6 +792,52 @@
paragraphs)]
(assoc content :children [(assoc paragraph-set :children new-paragraphs)])))
(defn- slice-paragraph
"`para` with only the text in [start, end), cutting the spans at both ends.
With no text left it keeps its first span, empty, for the style."
[para start end]
(let [spans (loop [spans (:children para)
pos 0
acc []]
(if (empty? spans)
acc
(let [span (first spans)
text (:text span)
span-end (+ pos (count text))
ol-start (max pos start)
ol-end (min span-end end)]
(recur (rest spans) span-end
(cond-> acc
(< ol-start ol-end)
(conj (assoc span :text (subs text (- ol-start pos) (- ol-end pos)))))))))]
(assoc para :children (if (seq spans)
spans
[(assoc (first (:children para)) :text "")]))))
(defn content-range
"The part of `content` inside the char range, with its paragraph and span styles."
[content {:keys [start-para start-offset end-para end-offset]}]
(let [paragraph-set (first (:children content))
paragraphs (->> (:children paragraph-set)
(map-indexed vector)
(keep (fn [[idx para]]
(when (<= start-para idx end-para)
(slice-paragraph para
(if (= idx start-para) start-offset 0)
(if (= idx end-para) end-offset (para-char-count para))))))
(vec))]
(assoc content :children [(assoc paragraph-set :children paragraphs)])))
(defn selection-content
"The content of the editor selection in `shape-id`, or nil when it is collapsed."
[shape-id]
(when-let [selection (text-editor-get-selection)]
(let [{:keys [start-para start-offset end-para end-offset] :as range}
(normalize-selection selection)]
(when-let [content (get-cached-content shape-id)]
(when-not (and (= start-para end-para) (= start-offset end-offset))
(content-range content range))))))
(defn- clean-styles
"Drop nil-valued attrs (unlike the DOM path, our merge would keep them and fail
the backend schema); a per-span fn is passed through untouched."

View File

@ -6,8 +6,9 @@
(ns app.render-wasm.text-paste
"Restyles the text WASM just inserted from a paste fragment (see
`app.util.text.clipboard`) with each run's overrides."
`app.util.text.clipboard`), with run overrides or full Penpot styles."
(:require
[app.common.types.text :as txt]
[app.main.fonts :as fonts]
[app.render-wasm.text-editor :as text-editor]
[cuerdas.core :as str]))
@ -90,3 +91,38 @@
(text-editor/apply-styles-over-range content range #(resolve-overrides % attrs)))
content
(styled-ranges fragment start)))
;; --- Text copied in Penpot
(def ^:private style-attrs
(into txt/paragraph-attrs txt/text-node-attrs))
(defn content->fragment
"The paste fragment for Penpot `content`. Its attrs are whole paragraph and span
styles, not overrides."
[content]
(->> (-> content :children first :children)
(mapv (fn [paragraph]
{:attrs (dissoc paragraph :type :children)
:children (into []
(comp (filter (comp seq :text))
(map (fn [span] {:text (:text span) :attrs (dissoc span :text)})))
(:children paragraph))}))))
(defn- restyle
"`node` with its style replaced by `attrs`."
[node attrs]
(merge (apply dissoc node style-attrs) attrs))
(defn apply-content-styles
"Restyles the Penpot `fragment` text WASM inserted at `start` in `content`. The
first paragraph keeps the style of the one it went into; the others bring their own."
[content fragment start]
(let [content (reduce (fn [content {:keys [range attrs]}]
(text-editor/apply-styles-over-range content range #(restyle % attrs)))
content
(styled-ranges fragment start))]
(reduce (fn [content [idx {:keys [attrs]}]]
(update-in content [:children 0 :children (+ (:para start) idx)] restyle attrs))
content
(rest (map-indexed vector fragment)))))

View File

@ -24,16 +24,14 @@
:allowHTMLPaste false})
(defn plain-text->html
"Build a minimal text/html clipboard payload from plain text.
Windows apps often prefer CF_HTML over CF_UNICODETEXT; writing only
text/plain from a contenteditable copy handler can leave them with the
editor surface's empty/`<br>` fallback (a lone newline)."
[text]
(let [escaped (-> (or text "")
(dom/escape-html)
(str/replace "\n" "<br>"))]
(str "<meta charset=\"utf-8\">" escaped)))
"Minimal text/html clipboard payload for `text`, with `hidden-html` before it.
Windows apps prefer CF_HTML, and may otherwise paste the editor's empty `<br>`."
([text] (plain-text->html text nil))
([text hidden-html]
(let [escaped (-> (or text "")
(dom/escape-html)
(str/replace "\n" "<br>"))]
(str "<meta charset=\"utf-8\">" hidden-html escaped))))
(defn- from-data-transfer
"Get clipboard stream from DataTransfer instance"
@ -225,6 +223,34 @@
(.readText ^js clipboard)
(unavailable-error))))
(defn- read-item-type
"Promise of the `mime` text of clipboard `item`, or of nil when it has none."
[^js item mime]
(if (some #{mime} (some-> item .-types array-seq))
(-> (.getType item mime) (.then #(.text ^js %)))
(js/Promise.resolve nil)))
(defn read-html-and-text
"Promise of the system clipboard as {:html :text}, either may be nil. Rejects like
`to-clipboard` when the asynchronous Clipboard API is not exposed."
[]
(let [clipboard (get-clipboard)]
(cond
(and clipboard (unchecked-get clipboard "read"))
(-> (.read ^js clipboard)
(.then (fn [items]
(let [item (first items)]
(-> (js/Promise.all #js [(read-item-type item "text/html")
(read-item-type item "text/plain")])
(.then (fn [[html text]] {:html html :text text})))))))
(and clipboard (unchecked-get clipboard "readText"))
(-> (.readText ^js clipboard)
(.then (fn [text] {:html nil :text text})))
:else
(unavailable-error))))
(defn permission-error?
"True for the `NotAllowedError` DOMException raised when access is denied."
[cause]

View File

@ -6,8 +6,14 @@
(ns app.util.text.clipboard
"Reads clipboard data into paste fragments: `[{:attrs {} :children [{:text :attrs}]}]`,
where run attrs are emphasis overrides laid over the style at the caret."
whose run attrs override the caret style; Penpot text also carries a hidden payload."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.fonts :as cfnt]
[app.common.transit :as t]
[app.common.types.text :as txt]
[app.common.types.typography :as ctt]
[cuerdas.core :as str]))
(def ^:private block-tags
@ -263,3 +269,96 @@
{:attrs {}
:children (if (empty? line) [] [{:text line :attrs {}}])})
(.split (str/replace text "\r" "") "\n"))))
;; --- Penpot payload
;;
;; Text copied in Penpot keeps its content in the `data-penpot-text` attr of an
;; empty span in the HTML: the one hidden marker every browser keeps.
(def ^:private payload-re #"data-penpot-text=\"([A-Za-z0-9+/=]*)\"")
(defn- encode-base64
[s]
(->> (.encode (js/TextEncoder.) s)
(.from js/Array)
(map #(.fromCharCode js/String %))
(str/join)
(js/btoa)))
(defn- decode-base64
[s]
(->> (.from js/Uint8Array (js/atob s) #(.charCodeAt % 0))
(.decode (js/TextDecoder.))))
(defn payload->html
"Hidden markup carrying `content`, copied from `file-id` in `team-id`."
[content file-id team-id]
(let [payload {:type :copied-text
:version 1
:file-id file-id
:team-id team-id
:content content}]
(str "<span data-penpot-text=\"" (encode-base64 (t/encode-str payload)) "\"></span>")))
(defn html->payload
"The Penpot payload in `html`, or nil when it has none."
[html]
(when-let [[_ encoded] (some->> html (re-find payload-re))]
(let [payload (ex/ignoring (t/decode-str (decode-base64 encoded)))]
(when (and (map? payload)
(= :copied-text (:type payload))
(map? (:content payload)))
payload))))
;; --- Pasting into another file or team
(def ^:private default-font
(d/seek #(= (:id %) (:font-id txt/default-text-attrs)) cfnt/local-fonts))
(defn- replace-custom-font
"`node` in the default font, at the closest weight and style it has."
[{:keys [font-weight font-style] :as node}]
(let [variant (or (cfnt/closest-variant (:variants default-font) (str font-weight) font-style)
(d/seek #(= (:id %) (:font-variant-id txt/default-text-attrs)) (:variants default-font)))]
(-> node
(assoc :font-id (:id default-font)
:font-family (:family default-font)
:font-variant-id (:id variant)
:font-weight (:weight variant)
:font-style (:style variant))
(ctt/remove-typography-from-node))))
(defn replace-custom-fonts
"`content` with every custom font replaced by the default one, for pasting it
into another team, where custom fonts have other ids."
[content]
(txt/transform-nodes #(= :custom (cfnt/font-id->backend (:font-id %)))
replace-custom-font
content))
(defn- drop-image-fills
"`node` without image fills, or with the default fill when it had only those."
[{:keys [fills] :as node}]
(if (some :fill-image fills)
(let [fills (into [] (remove :fill-image) fills)]
(assoc node :fills (if (seq fills) fills (txt/get-default-text-fills))))
node))
(defn clean-content
"`content` without what the paste target cannot reach: typographies outside
`valid-file-ids`, custom fonts from another team, images from another file."
[content {:keys [valid-file-ids same-team? same-file?]}]
(txt/transform-nodes
(fn [node]
(cond-> node
(and (some? (:typography-ref-file node))
(not (contains? valid-file-ids (:typography-ref-file node))))
(ctt/remove-typography-from-node)
(and (not same-team?)
(= :custom (cfnt/font-id->backend (:font-id node))))
(replace-custom-font)
(not same-file?)
(drop-image-fills)))
content))

View File

@ -0,0 +1,56 @@
;; 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-editor-content-range-test
"The styled content of a char range, as copied from the editor selection."
(:require
[app.render-wasm.text-editor :as text-editor]
[cljs.test :as t :include-macros true]))
(defn- span [text attrs]
(assoc attrs :text text))
(defn- paragraph [attrs & spans]
(assoc attrs :type "paragraph" :children (vec spans)))
(defn- content [& paragraphs]
{:type "root"
:children [{:type "paragraph-set" :children (vec paragraphs)}]})
(def ^:private bold {:font-weight "700"})
(def ^:private red {:fills [{:fill-color "#ff0000" :fill-opacity 1}]})
(def ^:private centered {:text-align "center"})
(def ^:private right {:text-align "right"})
(def ^:private source
(content (paragraph centered (span "Hello " {}) (span "bold" bold) (span " end" {}))
(paragraph right (span "red" red) (span " tail" {}))))
(t/deftest single-paragraph
(t/testing "spans are cut at both ends and keep their styles"
(t/is (= (content (paragraph centered (span "lo " {}) (span "bold" bold) (span " e" {})))
(text-editor/content-range source {:start-para 0 :start-offset 3
:end-para 0 :end-offset 12})))))
(t/deftest across-paragraphs
(t/testing "every paragraph in the range keeps its attrs"
(t/is (= (content (paragraph centered (span "end" {}))
(paragraph right (span "re" red)))
(text-editor/content-range source {:start-para 0 :start-offset 11
:end-para 1 :end-offset 2}))))
(t/testing "a range starting at a paragraph end keeps an empty paragraph with its style"
(t/is (= (content (paragraph centered (span "" {}))
(paragraph right (span "red" red)))
(text-editor/content-range source {:start-para 0 :start-offset 14
:end-para 1 :end-offset 3})))))
(t/deftest utf16-offsets
(t/testing "offsets count UTF-16 units like WASM's"
(t/is (= (content (paragraph {} (span "😀b" bold)))
(text-editor/content-range (content (paragraph {} (span "a😀b" bold)))
{:start-para 0 :start-offset 1
:end-para 0 :end-offset 4})))))

View File

@ -144,3 +144,49 @@
(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))))))

View File

@ -89,6 +89,7 @@
[frontend-tests.render-wasm.serialization-test]
[frontend-tests.render-wasm.text-editor-apply-styles-test]
[frontend-tests.render-wasm.text-editor-caret-color-test]
[frontend-tests.render-wasm.text-editor-content-range-test]
[frontend-tests.render-wasm.text-paste-test]
[frontend-tests.render-wasm.webgl-test]
[frontend-tests.router-test]
@ -239,6 +240,7 @@
'frontend-tests.render-wasm.serialization-test
'frontend-tests.render-wasm.text-editor-apply-styles-test
'frontend-tests.render-wasm.text-editor-caret-color-test
'frontend-tests.render-wasm.text-editor-content-range-test
'frontend-tests.render-wasm.text-paste-test
'frontend-tests.render-wasm.webgl-test
'frontend-tests.router-test

View File

@ -9,6 +9,8 @@
survives; the documents come from jsdom since the runner has no DOM."
(:require
["jsdom" :refer [JSDOM]]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.util.text.clipboard :as clipboard]
[cljs.test :as t :include-macros true]))
@ -174,3 +176,94 @@
(t/testing "empty paragraphs stay empty"
(t/is (= [(paragraph)]
(clipboard/without-overrides [(paragraph)])))))
;; --- Penpot payload
(def ^:private file-id (uuid/next))
(def ^:private team-id (uuid/next))
(defn- text-content [& spans]
{:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph" :text-align "center" :children (vec spans)}]}]})
(def ^:private gradient-span
{:text "Ñandú 😀 \"<b>\""
:font-id "custom-6e2c1a5e-1c1e-4b54-9c64-2a8c5f1a4c11"
:font-family "Brand Sans"
:font-variant-id "bold"
:font-weight "700"
:font-style "italic"
:fills [{:fill-color-gradient {:type :linear :start-x 0 :start-y 0 :end-x 1 :end-y 1 :width 1
:stops [{:color "#ff0000" :opacity 1 :offset 0}
{:color "#0000ff" :opacity 1 :offset 1}]}
:fill-opacity 1}]})
(t/deftest payload-round-trip
(let [content (text-content gradient-span)
hidden (clipboard/payload->html content file-id team-id)]
(t/testing "the payload decodes back to the same content, file and team"
(t/is (= {:type :copied-text :version 1 :file-id file-id :team-id team-id :content content}
(clipboard/html->payload (str "<meta charset=\"utf-8\">" hidden "Hello")))))
(t/testing "it survives the markup browsers add around it"
(let [safari (-> hidden
(.replace "></span>" " style=\"color: rgb(0, 0, 0); font-size: medium;\"></span>"))]
(t/is (= content
(:content (clipboard/html->payload
(str "<head><meta charset=\"UTF-8\"></head>" safari "Hello")))))))))
(t/deftest html-without-payload
(t/testing "HTML without a payload, or with a broken one, gives nil"
(t/is (nil? (clipboard/html->payload "<p>Hello</p>")))
(t/is (nil? (clipboard/html->payload "<span data-penpot-text=\"bm90IHRyYW5zaXQ=\"></span>")))
(t/is (nil? (clipboard/html->payload nil)))))
;; --- Clean-up
(def ^:private image-fill
{:fill-image {:id (uuid/next) :width 10 :height 10 :mtype "image/png"} :fill-opacity 1})
(def ^:private red-fill
{:fill-color "#ff0000" :fill-opacity 1})
(defn- spans-of [content]
(-> content :children first :children first :children))
(t/deftest clean-content
(let [library-id (uuid/next)
typography {:typography-ref-file library-id :typography-ref-id (uuid/next)}
content (text-content (merge gradient-span typography)
{:text "img" :fills [image-fill red-fill]}
{:text "only img" :fills [image-fill]})
same {:valid-file-ids #{library-id} :same-team? true :same-file? true}]
(t/testing "in the same file nothing changes"
(t/is (= content (clipboard/clean-content content same))))
(t/testing "typographies from unreachable files are dropped"
(t/is (nil? (-> (clipboard/clean-content content (assoc same :valid-file-ids #{}))
(spans-of)
(first)
:typography-ref-id))))
(t/testing "custom fonts from another team become the default font, at the closest variant"
(let [span (first (spans-of (clipboard/clean-content content (assoc same :same-team? false))))]
(t/is (= {:font-id "sourcesanspro" :font-family "sourcesanspro"
:font-variant-id "bolditalic" :font-weight "700" :font-style "italic"}
(select-keys span [:font-id :font-family :font-variant-id :font-weight :font-style])))
(t/is (nil? (:typography-ref-id span)))))
(t/testing "builtin and google fonts stay in another team"
(let [content (text-content (assoc gradient-span :font-id "gfont-roboto" :font-family "Roboto"))]
(t/is (= content (clipboard/clean-content content (assoc same :same-team? false))))))
(t/testing "images from another file are dropped, leaving the default fill when nothing is left"
(t/is (= [(:fills gradient-span) [red-fill] (txt/get-default-text-fills)]
(map :fills (spans-of (clipboard/clean-content content (assoc same :same-file? false)))))))))
(t/deftest replace-custom-fonts
(t/testing "only custom fonts become the default font"
(let [content (text-content gradient-span (assoc gradient-span :font-id "gfont-roboto" :font-family "Roboto"))]
(t/is (= ["sourcesanspro" "gfont-roboto"]
(map :font-id (spans-of (clipboard/replace-custom-fonts content))))))))