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))))))))