mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🎉 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:
parent
f252439d84
commit
cb1118f904
@ -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)]))]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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})))))
|
||||
@ -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))))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))))))))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user