diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs
index 4d365f0bee..557747d080 100644
--- a/frontend/src/app/main/data/workspace/clipboard.cljs
+++ b/frontend/src/app/main/data/workspace/clipboard.cljs
@@ -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)]))]
diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs
index 4ddaf0eded..1040ec1fd6 100644
--- a/frontend/src/app/main/data/workspace/texts.cljs
+++ b/frontend/src/app/main/data/workspace/texts.cljs
@@ -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
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs
index f3138a5238..b99b866d6d 100644
--- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs
+++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs
@@ -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 `
`."
+ [^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 `
`.
- (.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)
diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs
index 93f840767e..d85571e1f0 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -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")
diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs
index 6493128aba..eff80ba610 100644
--- a/frontend/src/app/render_wasm/text_editor.cljs
+++ b/frontend/src/app/render_wasm/text_editor.cljs
@@ -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."
diff --git a/frontend/src/app/render_wasm/text_paste.cljs b/frontend/src/app/render_wasm/text_paste.cljs
index afb72674f6..dbdd6cffec 100644
--- a/frontend/src/app/render_wasm/text_paste.cljs
+++ b/frontend/src/app/render_wasm/text_paste.cljs
@@ -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)))))
diff --git a/frontend/src/app/util/clipboard.cljs b/frontend/src/app/util/clipboard.cljs
index 3199a6f093..1f0b7decda 100644
--- a/frontend/src/app/util/clipboard.cljs
+++ b/frontend/src/app/util/clipboard.cljs
@@ -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/`
` fallback (a lone newline)."
- [text]
- (let [escaped (-> (or text "")
- (dom/escape-html)
- (str/replace "\n" "
"))]
- (str "" 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 `
`."
+ ([text] (plain-text->html text nil))
+ ([text hidden-html]
+ (let [escaped (-> (or text "")
+ (dom/escape-html)
+ (str/replace "\n" "
"))]
+ (str "" 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]
diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs
index 4c806fd1c4..f0f76bee6b 100644
--- a/frontend/src/app/util/text/clipboard.cljs
+++ b/frontend/src/app/util/text/clipboard.cljs
@@ -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 "")))
+
+(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))
diff --git a/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs b/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs
new file mode 100644
index 0000000000..675b1ea18a
--- /dev/null
+++ b/frontend/test/frontend_tests/render_wasm/text_editor_content_range_test.cljs
@@ -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})))))
diff --git a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs
index 7394d031a4..795309aff3 100644
--- a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs
+++ b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs
@@ -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))))))
diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs
index df7409c045..28d2c2419f 100644
--- a/frontend/test/frontend_tests/runner.cljs
+++ b/frontend/test/frontend_tests/runner.cljs
@@ -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
diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs
index d2d757fbb0..c0bcdbd07f 100644
--- a/frontend/test/frontend_tests/util_text_clipboard_test.cljs
+++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs
@@ -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รบ ๐ \"\""
+ :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 "" hidden "Hello")))))
+
+ (t/testing "it survives the markup browsers add around it"
+ (let [safari (-> hidden
+ (.replace ">" " style=\"color: rgb(0, 0, 0); font-size: medium;\">"))]
+ (t/is (= content
+ (:content (clipboard/html->payload
+ (str "" 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 "
Hello
"))) + (t/is (nil? (clipboard/html->payload ""))) + (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))))))))