🎉 Implement paste of basic HTML formatting (v3) (#12050)

* ✨ Add rich HTML paste to the v3 text editor

Pasting HTML into the v3 text editor keeps bold, italic, decoration and
text-transform, and drops fonts, colors and links. Gated behind
text-editor-wasm/v1-html-paste.

Refs #10465

AI-assisted-by: claude-opus-5-5

* ✨ Paste HTML on the canvas as a styled text shape

With v3 HTML paste on, pasting HTML on the canvas creates a text shape
with the same emphasis. Clipboard options are now read at paste time.

Refs #10465

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Belén Albeza 2026-10-02 15:07:12 +02:00 committed by GitHub
parent 8efbd9b5d3
commit 7c039231f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 756 additions and 24 deletions

View File

@ -56,6 +56,7 @@
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"text-editor-wasm/v1-html-paste"
"render-wasm/v1"
"variants/v1"})
@ -81,6 +82,7 @@
"text-editor/v2-html-paste"
"text-editor/v2"
"text-editor-wasm/v1"
"text-editor-wasm/v1-html-paste"
"tokens/numeric-input"
"render-wasm/v1"})
@ -131,6 +133,7 @@
:feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
:feature-text-editor-wasm "text-editor-wasm/v1"
:feature-text-editor-wasm-html-paste "text-editor-wasm/v1-html-paste"
:feature-render-wasm "render-wasm/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"

View File

@ -53,12 +53,14 @@
[app.main.router :as rt]
[app.main.store :as st]
[app.main.streams :as ms]
[app.render-wasm.text-paste :as text-paste]
[app.util.clipboard :as clipboard]
[app.util.code-gen.markup-svg :as svg]
[app.util.code-gen.style-css :as css]
[app.util.globals :as ug]
[app.util.http :as http]
[app.util.i18n :as i18n :refer [tr]]
[app.util.text.clipboard :as text-clipboard]
[app.util.text.content :as tc]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
@ -261,9 +263,15 @@
(declare ^:private paste-svg-text)
(declare ^:private paste-shapes)
(def ^:private default-options
(defn- v3-html-paste?
[state]
(and (features/active-feature? state "text-editor-wasm/v1")
(features/active-feature? state "text-editor-wasm/v1-html-paste")))
(defn- clipboard-options
[state]
#js {:decodeTransit t/decode-str
:allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")})
:allowHTMLPaste (v3-html-paste? state)})
(defn- create-paste-from-blob
[in-viewport? replace?]
@ -328,7 +336,7 @@
ptk/WatchEvent
(watch [_ state _]
(if (page-ready? (dsh/lookup-page-objects state))
(->> (clipboard/from-navigator default-options)
(->> (clipboard/from-navigator (clipboard-options state))
(rx/mapcat (create-paste-from-blob false (boolean replace?)))
(rx/take 1)
(rx/catch on-clipboard-permission-error))
@ -349,7 +357,7 @@
;; Pastes arriving before the page is loaded are ignored as well.
(if (or is-editing? (not (page-ready? objects)))
(rx/empty)
(->> (clipboard/from-synthetic-clipboard-event event default-options)
(->> (clipboard/from-synthetic-clipboard-event event (clipboard-options state))
(rx/mapcat (create-paste-from-blob in-viewport? false))))))))
(defn copy-selected-svg
@ -529,7 +537,7 @@
(rx/empty)))))]
(if (page-ready? (dsh/lookup-page-objects state))
(->> (clipboard/from-navigator default-options)
(->> (clipboard/from-navigator (clipboard-options state))
(rx/mapcat #(.text %))
(rx/map decode-entry)
(rx/take 1)
@ -1057,11 +1065,15 @@
(ptk/reify ::paste-html-text
ptk/WatchEvent
(watch [_ state _]
(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"))
text (.-textContent root)
content (tc/dom->cljs root)]
(when (types.text/valid-content? content)
(let [[text content]
(if (v3-html-paste? state)
(when-let [fragment (text-clipboard/html->fragment html)]
[(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)]))]
(when (and (some? content) (types.text/valid-content? content))
(let [id (uuid/next)
width (max 8 (min (* 7 (count text)) 700))
height 16

View File

@ -15,14 +15,17 @@
[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]))
@ -78,6 +81,16 @@
{: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
@ -87,12 +100,43 @@
;; 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)]
(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)))
(commit-restyled-content shape-id content)
(sync-wasm-text-editor-content!))))
;; 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."
[^js data html-paste?]
(let [html (when html-paste? (.getData data "text/html"))]
(or (when (< 0 (count html) max-paste-html-length)
(text-clipboard/html->fragment html))
(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."
[fragment]
(let [shape-id (text-editor/text-editor-get-active-shape-id)
start (selection-start)]
(text-editor/text-editor-insert-text (text-paste/fragment->text fragment))
(if (and (some? start) (text-paste/styled? fragment))
(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!)))
(sync-wasm-text-editor-content!))))
(defn- reset-input-node
@ -273,13 +317,12 @@
(dom/prevent-default event)
;; Pasted text keeps the surrounding style; drop any pending caret style.
(text-editor/clear-pending-caret-styles!)
(let [clipboard-data (.-clipboardData event)
text (.getData clipboard-data "text/plain")]
(when (and text (seq text))
(text-editor/text-editor-insert-text text)
(sync-wasm-text-editor-content!)
(wasm.api/request-render-preserving-target "text-paste"))
(reset-input-node (mf/ref-val contenteditable-ref)))))
(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"))
(reset-input-node (mf/ref-val contenteditable-ref))))
on-copy
(mf/use-fn

View File

@ -51,6 +51,7 @@
[app.render-wasm.performance :as perf]
[app.render-wasm.rulers-state :as rulers-state]
[app.render-wasm.text-editor :as text-editor]
[app.render-wasm.text-paste :as text-paste]
[app.util.debug :as dbg]
[app.util.dom :as dom]
[app.util.functions :as fns]
@ -805,6 +806,18 @@
(request-render "apply-pending-caret-styles")
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]
(when-let [content (text-editor/get-cached-content shape-id)]
(let [content (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")
{:shape-id shape-id
:content content})))
(defn set-parent-id
[id]
(let [buffer (uuid/get-u32 id)]

View File

@ -739,7 +739,7 @@
(= 1 (count fills-set)) (first fills-set)
:else :multiple)))
(defn- apply-styles-over-range
(defn apply-styles-over-range
"Apply `styles` (attrs map or per-span fn) to the char range of `content`, splitting spans."
[content {:keys [start-para start-offset end-para end-offset]} styles]
(let [paragraph-set (first (:children content))

View File

@ -0,0 +1,92 @@
;; 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 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."
(:require
[app.main.fonts :as fonts]
[app.render-wasm.text-editor :as text-editor]
[cuerdas.core :as str]))
(defn fragment->text
[fragment]
(->> fragment
(map (fn [paragraph] (str/join (map :text (:children paragraph)))))
(str/join "\n")))
(defn styled?
[fragment]
(some (fn [paragraph] (some (comp seq :attrs) (:children paragraph))) fragment))
(defn- styled-ranges
"The content range of every run with overrides, for a fragment inserted at
`start` (`{:para :offset}`, offsets in UTF-16 units like WASM's)."
[fragment {:keys [para offset]}]
(for [[idx paragraph] (map-indexed vector fragment)
:let [para-idx (+ para idx)
runs (:children paragraph)
starts (reductions + (if (zero? idx) offset 0)
(map (comp count :text) runs))]
[run run-start] (map vector runs starts)
:when (seq (:attrs run))]
{:attrs (:attrs run)
:range {:start-para para-idx
:start-offset run-start
:end-para para-idx
:end-offset (+ run-start (count (:text run)))}}))
(defn- target-weight
"The weight for `span` given a weight override. \"400\" means not bold, so it
only unbolds: a span below bold keeps its own weight."
[span font-weight]
(let [current (or (:font-weight span) "400")]
(if (and (= font-weight "400") (< (js/parseInt current 10) 600))
current
(or font-weight current))))
(defn- resolve-overrides
"`span` with `overrides`, weight and italic resolved to a variant its font has.
A changed span leaves its typography, which it no longer matches."
[span {:keys [font-weight font-style] :as overrides}]
(let [variant (when (or font-weight font-style)
(some-> (fonts/get-font-data (:font-id span))
(fonts/find-closest-variant
(target-weight span font-weight)
(or font-style (:font-style span) "normal"))))
result (cond-> (merge span (select-keys overrides [:text-decoration :text-transform]))
(some? variant)
(assoc :font-weight (:weight variant)
:font-style (:style variant)
:font-variant-id (:id variant)))]
(if (= result span)
span
(dissoc result :typography-ref-id :typography-ref-file))))
(defn fragment->content
"Penpot content for `fragment`, with `base` as the style its overrides go over."
[fragment base]
{:type "root"
:children
[{:type "paragraph-set"
:children
(mapv (fn [{:keys [children]}]
(merge base
{:type "paragraph"
:children (if (seq children)
(mapv (fn [{:keys [text attrs]}]
(resolve-overrides (assoc base :text text) attrs))
children)
[(assoc base :text "")])}))
fragment)}]})
(defn apply-fragment-styles
"Restyles the `fragment` text WASM inserted at `start` in `content`."
[content fragment start]
(reduce (fn [content {:keys [range attrs]}]
(text-editor/apply-styles-over-range content range #(resolve-overrides % attrs)))
content
(styled-ranges fragment start)))

View File

@ -0,0 +1,254 @@
;; 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 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."
(:require
[cuerdas.core :as str]))
(def ^:private block-tags
#{"ADDRESS" "ARTICLE" "ASIDE" "BLOCKQUOTE" "CAPTION" "DD" "DETAILS" "DIV" "DL"
"DT" "FIELDSET" "FIGCAPTION" "FIGURE" "FOOTER" "FORM" "H1" "H2" "H3" "H4"
"H5" "H6" "HEADER" "HR" "LI" "MAIN" "NAV" "OL" "P" "PRE" "SECTION"
"SUMMARY" "TABLE" "TBODY" "TFOOT" "THEAD" "TR" "UL"})
;; Elements whose text is read; any other element is skipped with its children.
;; `O:P` is Word's paragraph filler, which marks its empty lines.
(def ^:private allowed-tags
(into block-tags
#{"A" "ABBR" "B" "BDI" "BDO" "BIG" "BR" "CENTER" "CITE" "CODE" "DATA"
"DEL" "DFN" "EM" "FONT" "I" "INS" "KBD" "LABEL" "MARK" "O:P" "Q" "S"
"SAMP" "SMALL" "SPAN" "STRIKE" "STRONG" "SUB" "SUP" "TD" "TH" "TIME"
"TT" "U" "VAR" "WBR"}))
(def ^:private bold-tags #{"B" "STRONG" "TH" "H1" "H2" "H3" "H4" "H5" "H6"})
(def ^:private italic-tags #{"I" "EM" "CITE" "VAR" "DFN"})
(def ^:private underline-tags #{"U" "INS"})
(def ^:private strike-tags #{"S" "STRIKE" "DEL"})
(def ^:private cell-tags #{"TD" "TH"})
(defn- style-value
[^js element property]
(some-> (.-style element) (.getPropertyValue property) str/lower))
(defn- parse-bold
"True/false for a CSS font-weight, nil when it says nothing."
[weight]
(cond
(empty? weight) nil
(#{"bold" "bolder"} weight) true
(#{"normal" "lighter"} weight) false
:else (let [n (js/parseInt weight 10)]
(when-not (js/isNaN n) (>= n 600)))))
(defn- parse-italic
[font-style]
(cond
(empty? font-style) nil
(or (= font-style "italic") (str/starts-with? font-style "oblique")) true
(= font-style "normal") false
:else nil))
(defn- display-block?
[display]
(some #(str/starts-with? display %) ["block" "list-item" "table" "flex" "grid"]))
(defn- element-state
"The emphasis state inside `element`, or nil when hidden. Inline styles win over
tags, so Google Docs' `<b style=\"font-weight:normal\">` wrapper is not bold."
[state ^js element tag]
(let [display (or (style-value element "display") "")
weight (parse-bold (style-value element "font-weight"))
italic (parse-italic (style-value element "font-style"))
decoration (str (style-value element "text-decoration-line") " "
(style-value element "text-decoration"))
transform (style-value element "text-transform")
space (style-value element "white-space")]
(when-not (= display "none")
(cond-> state
(contains? bold-tags tag) (assoc :bold? true)
(some? weight) (assoc :bold? weight)
(contains? italic-tags tag) (assoc :italic? true)
(some? italic) (assoc :italic? italic)
(contains? underline-tags tag) (assoc :underline? true)
(str/includes? decoration "underline") (assoc :underline? true)
(contains? strike-tags tag) (assoc :strike? true)
(str/includes? decoration "line-through") (assoc :strike? true)
(= tag "A") (assoc :link? true)
(= tag "PRE") (assoc :pre? true)
(seq space) (assoc :pre? (str/starts-with? space "pre"))
(= transform "none") (dissoc :transform)
(#{"uppercase" "lowercase" "capitalize"} transform) (assoc :transform transform)))))
(defn- block-element?
[^js element tag]
(let [display (or (style-value element "display") "")]
(if (seq display)
(display-block? display)
(contains? block-tags tag))))
(defn- state->attrs
"Penpot text attrs for an emphasis state, with every attr set so the source
replaces the caret's emphasis. Link underlines go with the link."
[{:keys [bold? italic? underline? strike? link? transform]}]
(let [underline? (and underline? (not link?))]
{:font-weight (if bold? "700" "400")
:font-style (if italic? "italic" "normal")
:text-decoration (cond underline? "underline" strike? "line-through" :else "none")
:text-transform (or transform "none")}))
;; --- Fragment builder
;;
;; `:runs` is the paragraph being built; `:space?` is true when the next
;; collapsible space would be dropped (paragraph start or after a space).
(def ^:private empty-builder
{:paragraphs [] :runs [] :space? true})
(defn- trim-trailing-space
[runs]
(let [idx (dec (count runs))
run (get runs idx)]
(if (and run (not (:pre? run)))
(update runs idx update :text str/rtrim " ")
runs)))
(defn- close-paragraph
[{:keys [runs] :as builder}]
(-> builder
(update :paragraphs conj (trim-trailing-space runs))
(assoc :runs [] :space? true)))
(defn- soft-break
"Ends the paragraph at a block boundary, unless nothing was written yet."
[{:keys [runs] :as builder}]
(if (some #(seq (:text %)) runs)
(close-paragraph builder)
(assoc builder :runs [])))
(defn- add-run
[builder text attrs pre?]
(update builder :runs conj {:text text :attrs attrs :pre? pre?}))
(defn- add-collapsible-text
[{:keys [space?] :as builder} text attrs]
(let [text (cond-> (str/replace text #"[ \t\n\r\f]+" " ")
space? (str/ltrim " "))]
(if (empty? text)
builder
(-> builder
(add-run text attrs false)
(assoc :space? (str/ends-with? text " "))))))
(defn- add-pre-text
[builder text attrs]
(let [lines (.split (str/replace text "\r" "") "\n")]
(reduce (fn [builder [idx line]]
(cond-> builder
(pos? idx) (close-paragraph)
(seq line) (add-run line attrs true)
:always (assoc :space? false)))
builder
(map-indexed vector lines))))
(defn- add-text
[builder text state]
(let [attrs (state->attrs state)]
(if (:pre? state)
(add-pre-text builder text attrs)
(add-collapsible-text builder text attrs))))
(defn- walk-node
[builder ^js node state]
(case (.-nodeType node)
3 (add-text builder (.-nodeValue node) state)
1 (let [tag (str/upper (.-tagName node))]
(if-not (contains? allowed-tags tag)
builder
(if-let [state (element-state state node tag)]
(if (= tag "BR")
(close-paragraph builder)
(let [block? (block-element? node tag)
;; Cells of a row are joined by a tab.
cell? (and (contains? cell-tags tag)
(some? (.-previousElementSibling node)))
builder (cond-> builder
block? (soft-break)
cell? (-> (add-run "\t" (state->attrs state) true)
(assoc :space? true)))
builder (reduce #(walk-node %1 %2 state)
builder
(array-seq (.-childNodes node)))]
(cond-> builder block? (soft-break))))
builder)))
builder))
;; --- Normalizing
(defn- merge-runs
"Joins neighbouring runs that share attrs and drops empty ones."
[runs]
(reduce (fn [acc {:keys [text attrs]}]
(let [prev (peek acc)]
(cond
(empty? text) acc
(= (:attrs prev) attrs) (conj (pop acc) (update prev :text str text))
:else (conj acc {:text text :attrs attrs}))))
[]
runs))
(defn- runs->paragraph
[runs]
(let [runs (->> runs
(map #(update % :text str/replace " " " "))
(merge-runs))]
{:attrs {}
:children (if (every? #(str/blank? (:text %)) runs) [] runs)}))
(defn- empty-paragraph?
[paragraph]
(empty? (:children paragraph)))
(defn- normalize-paragraphs
"Drops empty paragraphs at both ends and keeps at most one in a row."
[paragraphs]
(let [paragraphs (->> paragraphs
(drop-while empty-paragraph?)
(reverse)
(drop-while empty-paragraph?)
(reverse))]
(->> paragraphs
(partition-by empty-paragraph?)
(mapcat #(if (empty-paragraph? (first %)) [(first %)] %))
(vec))))
(defn document->fragment
"The paste fragment for a parsed HTML document, or nil when it has no text."
[^js document]
(let [paragraphs (->> (array-seq (.-childNodes (.-body document)))
(reduce #(walk-node %1 %2 {}) empty-builder)
(soft-break)
:paragraphs
(map runs->paragraph)
(normalize-paragraphs))]
(when (seq paragraphs)
paragraphs)))
(defn html->fragment
[html]
(-> (js/DOMParser.)
(.parseFromString html "text/html")
(document->fragment)))
(defn text->fragment
"The paste fragment for plain text, one paragraph per line, or nil when empty."
[text]
(when (seq text)
(mapv (fn [line]
{:attrs {}
:children (if (empty? line) [] [{:text line :attrs {}}])})
(.split (str/replace text "\r" "") "\n"))))

View File

@ -0,0 +1,146 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.render-wasm.text-paste-test
"Restyling pasted text. Test content already holds the pasted text in the
style of the span it went into, as after `text-editor-sync-content`."
(:require
[app.render-wasm.text-paste :as text-paste]
[cljs.test :as t :include-macros true]))
(def ^:private regular
{:font-id "sourcesanspro" :font-variant-id "regular"
:font-weight "400" :font-style "normal"})
(defn- span
([text] (span text {}))
([text attrs] (merge regular attrs {:text text})))
(defn- content [& paragraphs]
{:type "root"
:children [{:type "paragraph-set"
:children (mapv (fn [spans] {:type "paragraph" :children spans})
paragraphs)}]})
(defn- spans-of [content]
(mapv :children (-> content :children first :children)))
(defn- fragment-paragraph [& runs]
{:attrs {} :children (vec runs)})
(defn- run
([text] (run text {}))
([text attrs] {:text text :attrs attrs}))
(def ^:private bold-variant
{:font-weight "700" :font-style "normal" :font-variant-id "bold"})
(t/deftest fragment->text
(t/testing "paragraphs are joined by newlines"
(t/is (= "ab\n\nc"
(text-paste/fragment->text
[(fragment-paragraph (run "a") (run "b"))
(fragment-paragraph)
(fragment-paragraph (run "c"))])))))
(t/deftest bold-run-resolves-to-a-font-variant
(t/testing "a bold run inside a paragraph gets the bold variant of the span's font"
(let [pasted (content [(span "Hello big world")])
fragment [(fragment-paragraph (run "big " {:font-weight "700"}))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 6})]
(t/is (= [[(span "Hello ") (span "big " bold-variant) (span "world")]]
(spans-of result))))))
(t/deftest italic-run-keeps-the-span-weight
(t/testing "italic over a bold span picks the bold italic variant"
(let [pasted (content [(span "x" bold-variant)])
fragment [(fragment-paragraph (run "x" {:font-style "italic"}))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})]
(t/is (= [[(span "x" {:font-weight "700" :font-style "italic"
:font-variant-id "bolditalic"})]]
(spans-of result))))))
(t/deftest html-emphasis-replaces-the-caret-emphasis
(let [no-emphasis {:font-weight "400" :font-style "normal"
:text-decoration "none" :text-transform "none"}]
(t/testing "plain source text pasted into bold, underlined text comes out plain"
(let [pasted (content [(span "aXb" (assoc bold-variant :text-decoration "underline"))])
fragment [(fragment-paragraph (run "X" no-emphasis))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 1})]
(t/is (= [[(span "a" (assoc bold-variant :text-decoration "underline"))
(span "X" {:text-decoration "none" :text-transform "none"})
(span "b" (assoc bold-variant :text-decoration "underline"))]]
(spans-of result)))))
(t/testing "not bold leaves a light weight as it is"
(let [light {:font-weight "300" :font-style "normal" :font-variant-id "300"}
pasted (content [(span "X" light)])
fragment [(fragment-paragraph (run "X" no-emphasis))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})]
(t/is (= [[(span "X" (assoc light :text-decoration "none" :text-transform "none"))]]
(spans-of result)))))))
(t/deftest unknown-font-keeps-its-weight
(t/testing "a font we have no data for cannot resolve a variant, so it stays"
(let [unknown (span "x" {:font-id "missing-font"})
fragment [(fragment-paragraph (run "x" {:font-weight "700"}))]
result (text-paste/apply-fragment-styles (content [unknown]) fragment {:para 0 :offset 0})]
(t/is (= [[unknown]] (spans-of result))))))
(t/deftest typography-link
(let [linked {:typography-ref-id "typo-id" :typography-ref-file "file-id"}]
(t/testing "a span changed by the paste leaves its typography"
(let [fragment [(fragment-paragraph (run "x" {:text-decoration "underline"}))]
result (text-paste/apply-fragment-styles
(content [(span "x" linked)]) fragment {:para 0 :offset 0})]
(t/is (= [[(span "x" {:text-decoration "underline"})]]
(spans-of result)))))
(t/testing "a span the paste leaves as it was keeps its typography"
(let [already (span "x" (merge bold-variant linked))
fragment [(fragment-paragraph (run "x" {:font-weight "700"}))]
result (text-paste/apply-fragment-styles (content [already]) fragment {:para 0 :offset 0})]
(t/is (= [[already]] (spans-of result)))))))
(t/deftest ranges-across-paragraphs
(t/testing "runs after the first paragraph start at offset 0 of their paragraph"
(let [pasted (content [(span "abX")] [(span "Ycd")])
fragment [(fragment-paragraph (run "X" {:font-weight "700"}))
(fragment-paragraph (run "Y" {:font-weight "700"}))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 2})]
(t/is (= [[(span "ab") (span "X" bold-variant)]
[(span "Y" bold-variant) (span "cd")]]
(spans-of result))))))
(t/deftest ranges-count-utf16-units
(t/testing "an emoji in the fragment moves later runs by its two UTF-16 units"
(let [pasted (content [(span "😀b")])
fragment [(fragment-paragraph (run "😀") (run "b" {:font-weight "700"}))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})]
(t/is (= [[(span "😀") (span "b" bold-variant)]]
(spans-of result)))))
(t/testing "a paste after an emoji starts at the UTF-16 offset WASM reports"
(let [pasted (content [(span "😀Xb")])
fragment [(fragment-paragraph (run "X" {:font-weight "700"}))]
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 2})]
(t/is (= [[(span "😀") (span "X" bold-variant) (span "b")]]
(spans-of result))))))
(t/deftest fragment->content
(t/testing "runs become spans over the base style, and empty paragraphs keep one empty span"
(let [result (text-paste/fragment->content
[(fragment-paragraph (run "Hello ") (run "bold" {:font-weight "700"}))
(fragment-paragraph)]
regular)]
(t/is (= [[(span "Hello ") (span "bold" bold-variant)]
[(span "")]]
(spans-of result)))))
(t/testing "paragraphs carry the base style"
(let [result (text-paste/fragment->content [(fragment-paragraph (run "x"))] regular)]
(t/is (= (assoc regular :type "paragraph")
(dissoc (-> result :children first :children first) :children))))))

View File

@ -85,6 +85,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-paste-test]
[frontend-tests.router-test]
[frontend-tests.svg-fills-test]
[frontend-tests.svg-filters-test]
@ -123,6 +124,7 @@
[frontend-tests.util-queue-test]
[frontend-tests.util-range-tree-test]
[frontend-tests.util-simple-math-test]
[frontend-tests.util-text-clipboard-test]
[frontend-tests.util-text-editor-test]
[frontend-tests.util-webapi-test]
[frontend-tests.util-zip-test]
@ -222,6 +224,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-paste-test
'frontend-tests.router-test
'frontend-tests.svg-fills-test
'frontend-tests.svg-filters-test
@ -261,6 +264,7 @@
'frontend-tests.util-queue-test
'frontend-tests.util-range-tree-test
'frontend-tests.util-simple-math-test
'frontend-tests.util-text-clipboard-test
'frontend-tests.util-text-editor-test
'frontend-tests.util-webapi-test
'frontend-tests.util.dom.dnd-test

View File

@ -0,0 +1,165 @@
;; 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.util-text-clipboard-test
"Clipboard HTML and plain text read into paste fragments. Only emphasis
survives; the documents come from jsdom since the runner has no DOM."
(:require
["jsdom" :refer [JSDOM]]
[app.util.text.clipboard :as clipboard]
[cljs.test :as t :include-macros true]))
(defn- html->fragment
[html]
(let [window (.-window (JSDOM. ""))
parser (new (.-DOMParser window))]
(clipboard/document->fragment (.parseFromString parser html "text/html"))))
(defn- paragraph [& runs]
{:attrs {} :children (vec runs)})
;; HTML runs carry every emphasis attr, so the source decides it.
(def ^:private no-emphasis
{:font-weight "400" :font-style "normal" :text-decoration "none" :text-transform "none"})
(defn- run
([text] (run text {}))
([text attrs] {:text text :attrs (merge no-emphasis attrs)}))
(defn- plain-run
[text]
{:text text :attrs {}})
(def ^:private bold {:font-weight "700"})
(def ^:private italic {:font-style "italic"})
(def ^:private google-docs-html
(str "<meta charset=\"utf-8\">"
"<b style=\"font-weight:normal;\" id=\"docs-internal-guid-1a2b3c\">"
"<p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\">"
"<span style=\"font-size:11pt;font-family:Arial,sans-serif;color:#000000;font-weight:400;font-style:normal;text-decoration:none;white-space:pre;white-space:pre-wrap;\">Hello </span>"
"<span style=\"font-size:11pt;font-family:Arial,sans-serif;color:#000000;font-weight:700;font-style:normal;text-decoration:none;white-space:pre;white-space:pre-wrap;\">bold</span>"
"<span style=\"font-size:11pt;font-family:Arial,sans-serif;color:#000000;font-weight:400;font-style:italic;text-decoration:none;white-space:pre;white-space:pre-wrap;\"> and italic</span>"
"</p><br>"
"<p dir=\"ltr\" style=\"line-height:1.38;margin-top:0pt;margin-bottom:0pt;\">"
"<span style=\"font-size:11pt;font-family:Arial,sans-serif;color:#000000;font-weight:400;white-space:pre-wrap;\">Second</span>"
"</p></b>"))
(def ^:private word-html
(str "<html xmlns:o=\"urn:schemas-microsoft-com:office:office\">"
"<head><style><!-- p.MsoNormal {margin:0cm;} --></style></head>"
"<body lang=EN-US><!--StartFragment-->"
"<p class=MsoNormal><span lang=EN-US>Plain <b>bold</b></span><o:p></o:p></p>\n"
"<p class=MsoNormal><o:p>&nbsp;</o:p></p>\n"
"<p class=MsoNormal><i><span lang=EN-US>italic</span></i><o:p></o:p></p>"
"<!--EndFragment--></body></html>"))
(def ^:private web-page-html
(str "<meta charset='utf-8'>"
"<h2 style=\"color: rgb(0, 0, 0); font-family: Georgia; font-size: 24px;\">Title</h2>"
"<p style=\"color: rgb(0, 0, 0); font-size: 16px; font-weight: 400;\">"
"Some <strong>strong</strong> and "
"<a href=\"https://example.com\" style=\"text-decoration: underline;\">a link</a>.</p>"
"<ul>\n <li>One</li>\n <li>Two</li>\n</ul>"))
(t/deftest google-docs-keeps-emphasis
(t/testing "the font-weight:normal wrapper does not make the whole paste bold"
(t/is (= [(paragraph (run "Hello ") (run "bold" bold) (run " and italic" italic))
(paragraph)
(paragraph (run "Second"))]
(html->fragment google-docs-html)))))
(t/deftest word-keeps-emphasis
(t/testing "Word markup and its empty &nbsp; paragraphs map to plain paragraphs"
(t/is (= [(paragraph (run "Plain ") (run "bold" bold))
(paragraph)
(paragraph (run "italic" italic))]
(html->fragment word-html)))))
(t/deftest web-page-drops-fonts-colors-links-and-list-markers
(t/testing "headings are bold, links lose their underline, list items become paragraphs"
(t/is (= [(paragraph (run "Title" bold))
(paragraph (run "Some ") (run "strong" bold) (run " and a link."))
(paragraph (run "One"))
(paragraph (run "Two"))]
(html->fragment web-page-html)))))
(t/deftest font-weight-threshold
(t/testing "600 and above is bold"
(t/is (= [(paragraph (run "semi" bold))]
(html->fragment "<span style=\"font-weight:600\">semi</span>"))))
(t/testing "light weights are ignored"
(t/is (= [(paragraph (run "light"))]
(html->fragment "<span style=\"font-weight:300\">light</span>")))))
(t/deftest decoration-and-transform
(t/testing "underline wins over line-through, as a span holds one decoration"
(t/is (= [(paragraph (run "both" {:text-decoration "underline"}))]
(html->fragment "<u><s>both</s></u>"))))
(t/testing "line-through alone is kept"
(t/is (= [(paragraph (run "gone" {:text-decoration "line-through"}))]
(html->fragment "<del>gone</del>"))))
(t/testing "text-transform is kept"
(t/is (= [(paragraph (run "loud" {:text-transform "uppercase"}))]
(html->fragment "<span style=\"text-transform:uppercase\">loud</span>")))))
(t/deftest whitespace
(t/testing "collapsible whitespace collapses and is trimmed at line ends"
(t/is (= [(paragraph (run "a b"))]
(html->fragment "<p> a \n b </p>"))))
(t/testing "&nbsp; is kept as a space"
(t/is (= [(paragraph (run "a b"))]
(html->fragment "<p>a&nbsp;&nbsp;b</p>"))))
(t/testing "pre keeps spaces and splits lines into paragraphs"
(t/is (= [(paragraph (run "line 1"))
(paragraph (run " line 2"))]
(html->fragment "<pre>line 1\n line 2</pre>")))))
(t/deftest line-breaks-and-empty-paragraphs
(t/testing "br starts a new paragraph"
(t/is (= [(paragraph (run "a")) (paragraph (run "b"))]
(html->fragment "a<br>b"))))
(t/testing "runs of empty lines collapse into one"
(t/is (= [(paragraph (run "a")) (paragraph) (paragraph (run "b"))]
(html->fragment "<p>a</p><br><br><br><p>b</p>"))))
(t/testing "empty lines at both ends are dropped"
(t/is (= [(paragraph (run "a"))]
(html->fragment "<br><p>a</p><br><br>")))))
(t/deftest tables
(t/testing "each row is a paragraph with its cells joined by a tab"
(t/is (= [(paragraph (run "a\tb")) (paragraph (run "c\td"))]
(html->fragment
"<table><tr><td>a</td><td> b</td></tr><tr><td>c</td><td>d</td></tr></table>")))))
(t/deftest hidden-content-is-skipped
(t/testing "style, script and display:none give no text"
(t/is (= [(paragraph (run "shown"))]
(html->fragment
"<style>p{color:red}</style><p>shown</p><script>x()</script><p style=\"display:none\">hidden</p>"))))
(t/testing "elements outside the allowlist are skipped with their text"
(t/is (= [(paragraph (run "kept"))]
(html->fragment
"<p>kept<my-widget>custom</my-widget><button>Buy</button><textarea>typed</textarea></p>"))))
(t/testing "HTML without text gives no fragment"
(t/is (nil? (html->fragment "<img src=\"x.png\">")))))
(t/deftest plain-text
(t/testing "each line is a paragraph and CRLF counts as one break"
(t/is (= [(paragraph (plain-run "a")) (paragraph) (paragraph (plain-run "b"))]
(clipboard/text->fragment "a\r\n\r\nb"))))
(t/testing "empty text gives no fragment"
(t/is (nil? (clipboard/text->fragment "")))))