mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🐛 Fix pasted gradient fills in text editor v3 (#12516)
* 🐛 Keep text gradients in place when pasting at the caret Text gradients are stored as fractions of the whole text box. Paste copied those fractions unchanged, so in a box of another size the handles and colours moved away from the pasted text. Copy from the v3 editor now records where each span with a gradient or image fill starts in its text box, and the box size, in the clipboard payload. A new WASM export, text_editor_get_range_origin, gives the start of any text range. Paste at the caret finds where those spans start in the target and rewrites their gradients so the handles keep the same pixel offset from the span. Auto-sized texts use the box size they get when the editor closes. AI-assisted-by: claude-opus-5-5 * 🐛 Rebuild text gradient shaders when the text box resizes Text gradient and image shaders are built into the cached Skia paragraphs against the text box size. A resize with no content change reused those paragraphs, so the colours stayed matched to the old box until a reload. The text now remembers the box size its shaders were built for and lays itself out again when that size changes and a span has a gradient or image fill. Text with only solid fills keeps its cache. AI-assisted-by: claude-opus-5-5 * 🐛 Keep text gradients in place when pasting on the canvas A text selection pasted on the canvas becomes a new text whose box differs from the source, so its gradients moved like they did for caret paste. After adding the new text, lay it out, find where its gradient spans start and move their gradients to keep the same pixel offset from the span, in the same undo step. The range origin export now takes a shape id, so it works on texts that are not being edited. AI-assisted-by: claude-opus-5-5 * 🐛 Keep text gradients in place when pasting copied texts at the caret Text shapes copied whole, rather than as an editor selection, reached the caret paste without any geometry, so their gradients still moved away from the pasted text. A shape copy now records where the gradient and image spans of each selected text start, and the box size, by shape id. Paste joins them in the order of the joined paragraphs, with no origin for texts copied without one. Copied texts each have their own box, so every span origin now carries its own box size instead of one box for the whole payload. AI-assisted-by: claude-opus-5-5
This commit is contained in:
parent
347a52d514
commit
0505eb3b1d
@ -209,14 +209,18 @@
|
||||
file (dsh/lookup-file state file-id)
|
||||
version (get file :version)
|
||||
|
||||
initial {:type :copied-shapes
|
||||
:features features
|
||||
:version version
|
||||
:file-id file-id
|
||||
:team-id (:current-team-id state)
|
||||
:selected selected
|
||||
:objects {}
|
||||
:images #{}}
|
||||
geometry (dwtxt/copied-text-geometry state objects selected)
|
||||
|
||||
initial (cond-> {:type :copied-shapes
|
||||
:features features
|
||||
:version version
|
||||
:file-id file-id
|
||||
:team-id (:current-team-id state)
|
||||
:selected selected
|
||||
:objects {}
|
||||
:images #{}}
|
||||
(some? geometry)
|
||||
(assoc :text-geometry geometry))
|
||||
|
||||
shapes (->> (cfh/selected-with-children objects selected)
|
||||
(keep (d/getf objects)))]
|
||||
@ -584,6 +588,7 @@
|
||||
[:objects
|
||||
[:map-of ::sm/uuid :map]]
|
||||
[:images [:set :map]]
|
||||
[:text-geometry {:optional true} [:map-of ::sm/uuid :map]]
|
||||
[:position {:optional true} ::gpt/point]])
|
||||
|
||||
(def ^:private
|
||||
@ -1075,9 +1080,11 @@
|
||||
(ptk/reify ::paste-html-text
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [[text content]
|
||||
(let [payload (when (v3-html-paste? state)
|
||||
(text-clipboard/html->payload html))
|
||||
[text content]
|
||||
(if (v3-html-paste? state)
|
||||
(if-let [payload (text-clipboard/html->payload html)]
|
||||
(if (some? payload)
|
||||
(let [content (dwtxt/payload-content state payload)]
|
||||
[(txt/content->text content) content])
|
||||
(when-let [fragment (some-> (text-clipboard/html->fragment html)
|
||||
@ -1109,7 +1116,8 @@
|
||||
(dwsh/create-and-add-shape :text x y shape
|
||||
(when skip-edition? {:skip-edition? true})))
|
||||
(if skip-edition?
|
||||
(rx/of (dwwt/resize-wasm-text-debounce id {:undo-group id
|
||||
(rx/of (dwtxt/move-new-text-gradients id payload)
|
||||
(dwwt/resize-wasm-text-debounce id {:undo-group id
|
||||
:undo-id undo-id}))
|
||||
(rx/of (dwu/commit-undo-transaction undo-id))))))))))
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.math :as mth]
|
||||
[app.common.render-wasm.api.select :as wselect]
|
||||
[app.common.transit :as t]
|
||||
[app.common.types.fills :as types.fills]
|
||||
[app.common.types.modifiers :as ctm]
|
||||
@ -1563,13 +1564,17 @@
|
||||
(wasm.text-editor/text-editor-has-selection?))
|
||||
(let [text (wasm.text-editor/text-editor-export-selection)]
|
||||
(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)))]
|
||||
(let [shape-id (wasm.text-editor/text-editor-get-active-shape-id)
|
||||
content (when (features/active-feature? state "text-editor-wasm/v1-html-paste")
|
||||
(wasm.text-editor/selection-content shape-id))
|
||||
geometry (when (some? content)
|
||||
(wasm.text-editor/content-geometry shape-id content
|
||||
(wasm.text-editor/selection-start)))
|
||||
payload (when (some? content)
|
||||
(text-clipboard/payload->html content
|
||||
(:current-file-id state)
|
||||
(:current-team-id state)
|
||||
geometry))]
|
||||
{"text/plain" text
|
||||
"text/html" (clipboard/plain-text->html text payload)})))))
|
||||
|
||||
@ -1634,25 +1639,51 @@
|
||||
(when (and (map? data) (= :copied-shapes (:type data)))
|
||||
data))))
|
||||
|
||||
(defn copied-text-geometry
|
||||
"The geometry (see `text-editor/content-geometry`) of each selected text in
|
||||
`objects` with gradient or image spans, by id, for a shape copy; nil when none."
|
||||
[state objects selected]
|
||||
(when (features/active-feature? state "text-editor-wasm/v1-html-paste")
|
||||
(not-empty
|
||||
(into {}
|
||||
(keep (fn [id]
|
||||
(let [{:keys [content] :as shape} (get objects id)]
|
||||
(when (cfh/text-shape? shape)
|
||||
(some->> (wasm.text-editor/content-geometry id content {:para 0 :offset 0})
|
||||
(vector id))))))
|
||||
selected))))
|
||||
|
||||
(defn- text-origins
|
||||
"The span origins of `text` in `text-geometry`, nil for each span without one."
|
||||
[text-geometry {:keys [id content]}]
|
||||
(or (get-in text-geometry [id :origins])
|
||||
(mapv #(mapv (constantly nil) (:children %))
|
||||
(-> content :children first :children))))
|
||||
|
||||
(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]}]
|
||||
[{:keys [objects selected file-id team-id text-geometry]}]
|
||||
(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))})))
|
||||
(cond-> {:file-id file-id
|
||||
:team-id team-id
|
||||
:content (assoc-in (:content (first texts))
|
||||
[:children 0 :children]
|
||||
(into [] (mapcat #(-> % :content :children first :children)) texts))}
|
||||
(some? text-geometry)
|
||||
(assoc :geometry {:origins (into [] (mapcat (partial text-origins text-geometry)) texts)})))))
|
||||
|
||||
(defn- penpot-paste
|
||||
[state payload]
|
||||
{:penpot? true
|
||||
:fragment (text-paste/content->fragment (payload-content state payload))})
|
||||
(let [content (payload-content state payload)]
|
||||
{:penpot? true
|
||||
:content content
|
||||
:geometry (:geometry payload)
|
||||
:fragment (text-paste/content->fragment content)}))
|
||||
|
||||
(defn- clipboard->paste
|
||||
"What to paste for clipboard `html` and `text`, or nil: text and text shapes copied
|
||||
@ -1676,10 +1707,63 @@
|
||||
{:penpot? false
|
||||
:fragment fragment}))))
|
||||
|
||||
(defn- target-geometry
|
||||
"Where the gradient spans of `pasted`, inserted at `start`, begin in `shape` with
|
||||
`content`, laid over the box it gets once resized to its text; nil when none."
|
||||
[{:keys [id grow-type]} content pasted start]
|
||||
(when-let [geometry (wasm.text-editor/content-geometry id pasted start)]
|
||||
(if (= :fixed grow-type)
|
||||
geometry
|
||||
;; Auto-sized boxes only fit their text when resized (for the editor, when it
|
||||
;; closes), which drops the vertical-align offset in the measured origins.
|
||||
(let [{:keys [width height]} (wasm.api/get-text-dimensions id)
|
||||
fit (fn [{:keys [box] :as origin}]
|
||||
(let [offset (case (some-> (:vertical-align content) name)
|
||||
"center" (/ (- (:height box) height) 2)
|
||||
"bottom" (- (:height box) height)
|
||||
0)]
|
||||
(-> origin
|
||||
(update :y - offset)
|
||||
(assoc :box {:width (if (= :auto-width grow-type) width (:width box))
|
||||
:height height}))))]
|
||||
(update geometry :origins (partial mapv (partial mapv #(some-> % fit))))))))
|
||||
|
||||
(defn- move-pasted-gradients
|
||||
"`content`, the shape's after restyling the paste, with the pasted gradients
|
||||
keeping their place on their spans. Pushes the result to WASM."
|
||||
[shape content {:keys [geometry] :as paste} start]
|
||||
(let [pasted (:content paste)
|
||||
target (when (some? geometry) (target-geometry shape content pasted start))]
|
||||
(if (some? target)
|
||||
(let [content (text-paste/move-gradients content pasted start geometry target)]
|
||||
(wasm.api/set-shape-text-content (:id shape) content)
|
||||
content)
|
||||
content)))
|
||||
|
||||
(defn move-new-text-gradients
|
||||
"Event moving the gradients of `payload`, just pasted on the canvas as the text
|
||||
`id`, so they keep their place on their spans."
|
||||
[id {:keys [geometry]}]
|
||||
(ptk/reify ::move-new-text-gradients
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [{:keys [content] :as shape} (dsh/lookup-shape state id)
|
||||
start {:para 0 :offset 0}
|
||||
target (when (and (some? shape) (some? geometry) (wasm.api/initialized?))
|
||||
(wselect/use-shape id)
|
||||
(wasm.api/set-shape-text-content id content)
|
||||
(target-geometry shape content content start))]
|
||||
(if (some? target)
|
||||
(rx/of (dwsh/update-shapes
|
||||
[id]
|
||||
#(update % :content text-paste/move-gradients content start geometry target)
|
||||
{:attrs #{:content}}))
|
||||
(rx/empty))))))
|
||||
|
||||
(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?]}]
|
||||
[state {:keys [fragment penpot?] :as paste}]
|
||||
(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))
|
||||
@ -1696,7 +1780,8 @@
|
||||
;; 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?)]
|
||||
(v3-update-editor-content shape-id content)
|
||||
(let [shape (dsh/lookup-shape state shape-id)]
|
||||
(v3-update-editor-content shape-id (move-pasted-gradients shape content paste start)))
|
||||
(v3-sync-editor-content)))
|
||||
|
||||
pending?
|
||||
@ -1713,7 +1798,7 @@
|
||||
(watch [_ state _]
|
||||
(if-let [paste (when (wasm.text-editor/text-editor-has-focus?)
|
||||
(clipboard->paste state html text))]
|
||||
(let [event (insert-fragment paste)]
|
||||
(let [event (insert-fragment state paste)]
|
||||
(wasm.api/request-render-preserving-target "text-paste")
|
||||
(if (some? event) (rx/of event) (rx/empty)))
|
||||
(rx/empty)))))
|
||||
|
||||
@ -838,6 +838,66 @@
|
||||
(when-not (and (= start-para end-para) (= start-offset end-offset))
|
||||
(content-range content range))))))
|
||||
|
||||
(defn span-ranges
|
||||
"The char range of every span of `content`, a slice that starts at `start`
|
||||
(`{:para :offset}`) in its shape: one vector of ranges per paragraph."
|
||||
[content {:keys [para offset]}]
|
||||
(into []
|
||||
(map-indexed
|
||||
(fn [idx paragraph]
|
||||
(let [para-idx (+ para idx)
|
||||
spans (:children paragraph)
|
||||
starts (reductions + (if (zero? idx) offset 0) (map (comp count :text) spans))]
|
||||
(mapv (fn [span span-start]
|
||||
{:start-para para-idx
|
||||
:start-offset span-start
|
||||
:end-para para-idx
|
||||
:end-offset (+ span-start (count (:text span)))})
|
||||
spans
|
||||
starts))))
|
||||
(-> content :children first :children)))
|
||||
|
||||
(defn- range-origin
|
||||
"Where the text in `range` starts in the text `shape-id`, shape-local, with the size
|
||||
of the box its fills are laid over: {:x :y :box {:width :height}}, or nil."
|
||||
[shape-id {:keys [start-para start-offset end-para end-offset]}]
|
||||
(when (wasm/ready?)
|
||||
(let [id (uuid/get-u32 shape-id)
|
||||
byte-offset (mem/alloc 16)
|
||||
f32-offset (mem/->offset-32 byte-offset)
|
||||
heap (mem/get-heap-f32)
|
||||
has-text? (h/call wasm/internal-module "_text_get_range_origin"
|
||||
(aget id 0) (aget id 1) (aget id 2) (aget id 3)
|
||||
start-para start-offset end-para end-offset byte-offset)
|
||||
result (when has-text?
|
||||
{:x (aget heap f32-offset)
|
||||
:y (aget heap (+ f32-offset 1))
|
||||
:box {:width (aget heap (+ f32-offset 2))
|
||||
:height (aget heap (+ f32-offset 3))}})]
|
||||
(mem/free)
|
||||
result)))
|
||||
|
||||
(defn- box-relative-fill?
|
||||
[fill]
|
||||
(or (some? (:fill-color-gradient fill))
|
||||
(some? (:fill-image fill))))
|
||||
|
||||
(defn content-geometry
|
||||
"Where the gradient or image spans of `content`, a slice starting at `start`, begin
|
||||
in the text `shape-id`, each with its box size; nil when it has none."
|
||||
[shape-id content start]
|
||||
(let [ranges (span-ranges content start)
|
||||
origins (mapv (fn [paragraph paragraph-ranges]
|
||||
(mapv (fn [span range]
|
||||
(when (some box-relative-fill? (:fills span))
|
||||
(range-origin shape-id range)))
|
||||
(:children paragraph)
|
||||
paragraph-ranges))
|
||||
(-> content :children first :children)
|
||||
ranges)]
|
||||
(when (some some? (flatten origins))
|
||||
{:origins origins})))
|
||||
|
||||
(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. A nil
|
||||
|
||||
@ -126,3 +126,50 @@
|
||||
(update-in content [:children 0 :children (+ (:para start) idx)] restyle attrs))
|
||||
content
|
||||
(rest (map-indexed vector fragment)))))
|
||||
|
||||
;; --- Gradients of text copied in Penpot
|
||||
|
||||
(defn- move-gradient
|
||||
"`gradient`, in fractions of the `from` box, in fractions of the `to` box, with its
|
||||
points at the same pixel offset from the span origin."
|
||||
[gradient from to]
|
||||
(let [{x0 :x y0 :y {w0 :width h0 :height} :box} from
|
||||
{x1 :x y1 :y {w1 :width h1 :height} :box} to
|
||||
move-x #(/ (+ x1 (- (* % w0) x0)) w1)
|
||||
move-y #(/ (+ y1 (- (* % h0) y0)) h1)]
|
||||
(cond-> (-> gradient
|
||||
(update :start-x move-x)
|
||||
(update :start-y move-y)
|
||||
(update :end-x move-x)
|
||||
(update :end-y move-y))
|
||||
;; A radial width is scaled by the box aspect ratio when drawn.
|
||||
(= :radial (:type gradient))
|
||||
(update :width #(/ (* % w0 h1) (* h0 w1))))))
|
||||
|
||||
(defn- move-fills
|
||||
[span from to]
|
||||
(update span :fills
|
||||
(fn [fills]
|
||||
(mapv #(cond-> %
|
||||
(some? (:fill-color-gradient %))
|
||||
(update :fill-color-gradient move-gradient from to))
|
||||
fills))))
|
||||
|
||||
(defn- sized?
|
||||
[{{:keys [width height]} :box}]
|
||||
(and (pos? width) (pos? height)))
|
||||
|
||||
(defn move-gradients
|
||||
"`content` with the gradients of `pasted` (the content inserted at `start`) moved
|
||||
from the `source` geometry to the `target` one (see `text-editor/content-geometry`)."
|
||||
[content pasted start source target]
|
||||
(let [entries (map vector
|
||||
(flatten (text-editor/span-ranges pasted start))
|
||||
(flatten (:origins source))
|
||||
(flatten (:origins target)))]
|
||||
(reduce (fn [content [range from to]]
|
||||
(if (and (some? from) (some? to) (sized? from) (sized? to))
|
||||
(text-editor/apply-styles-over-range content range #(move-fills % from to))
|
||||
content))
|
||||
content
|
||||
entries)))
|
||||
|
||||
@ -291,14 +291,18 @@
|
||||
(.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>")))
|
||||
"Hidden markup carrying `content`, copied from `file-id` in `team-id`, and the
|
||||
`geometry` of its spans in their text box (see `text-editor/content-geometry`)."
|
||||
([content file-id team-id]
|
||||
(payload->html content file-id team-id nil))
|
||||
([content file-id team-id geometry]
|
||||
(let [payload (cond-> {:type :copied-text
|
||||
:version 1
|
||||
:file-id file-id
|
||||
:team-id team-id
|
||||
:content content}
|
||||
(some? geometry) (assoc :geometry geometry))]
|
||||
(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."
|
||||
|
||||
@ -626,3 +626,33 @@
|
||||
result (dwt/merge-live-text-dimensions :auto-width selrect {:width 0 :height 0})]
|
||||
(t/is (= 50 (:width result)))
|
||||
(t/is (= 25 (:height result))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; copied-shapes->payload
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- copied-text
|
||||
[id y & spans]
|
||||
{:id id
|
||||
:type :text
|
||||
:x 0
|
||||
:y y
|
||||
:content {:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children (mapv #(hash-map :text %) spans)}]}]}})
|
||||
|
||||
(t/deftest copied-shapes-payload-joins-text-geometry
|
||||
(let [top-id (uuid/next)
|
||||
bottom-id (uuid/next)
|
||||
origin {:x 4 :y 2 :box {:width 100 :height 20}}
|
||||
data {:file-id (uuid/next)
|
||||
:selected #{bottom-id top-id}
|
||||
:objects {top-id (copied-text top-id 0 "a" "b")
|
||||
bottom-id (copied-text bottom-id 50 "c")}}]
|
||||
(t/testing "the origins follow the joined paragraphs, nil for texts without geometry"
|
||||
(t/is (= {:origins [[nil nil] [origin]]}
|
||||
(:geometry (#'dwt/copied-shapes->payload
|
||||
(assoc data :text-geometry {bottom-id {:origins [[origin]]}}))))))
|
||||
(t/testing "a copy without geometry gives a payload without it"
|
||||
(t/is (not (contains? (#'dwt/copied-shapes->payload data) :geometry))))))
|
||||
|
||||
@ -54,3 +54,19 @@
|
||||
(text-editor/content-range (content (paragraph {} (span "a😀b" bold)))
|
||||
{:start-para 0 :start-offset 1
|
||||
:end-para 0 :end-offset 4})))))
|
||||
|
||||
(t/deftest span-ranges
|
||||
(let [slice (text-editor/content-range source {:start-para 0 :start-offset 3
|
||||
:end-para 1 :end-offset 5})]
|
||||
(t/testing "the first paragraph's spans start at the slice start; later ones at 0"
|
||||
(t/is (= [[{:start-para 0 :start-offset 3 :end-para 0 :end-offset 6}
|
||||
{:start-para 0 :start-offset 6 :end-para 0 :end-offset 10}
|
||||
{:start-para 0 :start-offset 10 :end-para 0 :end-offset 14}]
|
||||
[{:start-para 1 :start-offset 0 :end-para 1 :end-offset 3}
|
||||
{:start-para 1 :start-offset 3 :end-para 1 :end-offset 5}]]
|
||||
(text-editor/span-ranges slice {:para 0 :offset 3})))))
|
||||
|
||||
(t/testing "offsets count UTF-16 units like WASM's"
|
||||
(t/is (= [[{:start-para 2 :start-offset 1 :end-para 2 :end-offset 4}]]
|
||||
(text-editor/span-ranges (content (paragraph {} (span "😀b" bold)))
|
||||
{:para 2 :offset 1})))))
|
||||
|
||||
@ -190,3 +190,75 @@
|
||||
(t/is (= [[(span "A") (assoc brand :text "x")]
|
||||
[(assoc brand :text "y") (span "B")]]
|
||||
(spans-of result))))))
|
||||
|
||||
;; --- Gradients
|
||||
|
||||
(defn- gradient-fill [gradient]
|
||||
{:fills [{:fill-color-gradient (merge {:type :linear :width 1 :stops []} gradient)}]})
|
||||
|
||||
(def ^:private source-box {:width 200 :height 40})
|
||||
|
||||
(def ^:private target-box {:width 100 :height 80})
|
||||
|
||||
(def ^:private source-geometry
|
||||
{:origins [[{:x 50 :y 0 :box source-box}]]})
|
||||
|
||||
(def ^:private target-geometry
|
||||
{:origins [[{:x 10 :y 40 :box target-box}]]})
|
||||
|
||||
(defn- pasted-gradient [content]
|
||||
(-> (spans-of content) first second :fills first :fill-color-gradient))
|
||||
|
||||
(t/deftest gradients-keep-their-offset-from-the-span
|
||||
(let [linear (gradient-fill {:start-x 0.25 :start-y 0 :end-x 0.75 :end-y 1})
|
||||
pasted (content [(span "HELLO" linear)])
|
||||
target (content [(span "abc") (span "HELLO" linear)])
|
||||
result (text-paste/move-gradients target pasted {:para 0 :offset 3}
|
||||
source-geometry target-geometry)]
|
||||
(t/testing "points move into the target box at the same pixel offsets"
|
||||
(t/is (= {:start-x 0.1 :start-y 0.5 :end-x 1.1 :end-y 1}
|
||||
(select-keys (pasted-gradient result) [:start-x :start-y :end-x :end-y]))))
|
||||
(t/testing "a linear width stays as it was"
|
||||
(t/is (= 1 (:width (pasted-gradient result)))))
|
||||
(t/testing "text before the paste keeps its fills"
|
||||
(t/is (= (span "abc") (-> (spans-of result) first first))))))
|
||||
|
||||
(t/deftest radial-width-follows-the-aspect-ratio
|
||||
(let [radial (gradient-fill {:type :radial :start-x 0.5 :start-y 0.5 :end-x 0.5 :end-y 1})
|
||||
pasted (content [(span "HELLO" radial)])
|
||||
target (content [(span "abc") (span "HELLO" radial)])
|
||||
result (text-paste/move-gradients target pasted {:para 0 :offset 3}
|
||||
source-geometry target-geometry)]
|
||||
;; 200x40 is 5:1 and 100x80 is 5:4, so the width grows 4 times.
|
||||
(t/is (= 4 (:width (pasted-gradient result))))))
|
||||
|
||||
(t/deftest spans-without-geometry-are-left-alone
|
||||
(let [red {:fills [{:fill-color "#ff0000" :fill-opacity 1}]}
|
||||
linear (gradient-fill {:start-x 0 :start-y 0 :end-x 1 :end-y 1})
|
||||
pasted (content [(span "red" red) (span "grad" linear)])
|
||||
target (content [(span "red" red) (span "grad" linear)])]
|
||||
(t/testing "a span with no origin keeps its gradient"
|
||||
(t/is (= target (text-paste/move-gradients
|
||||
target pasted {:para 0 :offset 0}
|
||||
{:origins [[nil nil]]}
|
||||
{:origins [[nil nil]]}))))
|
||||
(t/testing "an empty box leaves the gradients as they were"
|
||||
(t/is (= target (text-paste/move-gradients
|
||||
target pasted {:para 0 :offset 0}
|
||||
{:origins [[nil {:x 0 :y 0 :box source-box}]]}
|
||||
{:origins [[nil {:x 0 :y 0 :box {:width 0 :height 80}}]]}))))))
|
||||
|
||||
(t/deftest each-span-moves-from-its-own-box
|
||||
;; Copied text shapes are joined into one paste, each span keeping its box.
|
||||
(let [linear (gradient-fill {:start-x 0.5 :start-y 0 :end-x 0.5 :end-y 1})
|
||||
pasted (content [(span "a" linear)] [(span "b" linear)])
|
||||
source {:origins [[{:x 0 :y 0 :box {:width 100 :height 20}}]
|
||||
[{:x 0 :y 0 :box {:width 200 :height 40}}]]}
|
||||
target {:origins [[{:x 0 :y 0 :box {:width 400 :height 80}}]
|
||||
[{:x 0 :y 40 :box {:width 400 :height 80}}]]}
|
||||
result (text-paste/move-gradients pasted pasted {:para 0 :offset 0} source target)
|
||||
points #(select-keys (-> % :fills first :fill-color-gradient)
|
||||
[:start-x :start-y :end-x :end-y])]
|
||||
(t/is (= [{:start-x 0.125 :start-y 0 :end-x 0.125 :end-y 0.25}
|
||||
{:start-x 0.25 :start-y 0.5 :end-x 0.25 :end-y 1}]
|
||||
(map (comp points first) (spans-of result))))))
|
||||
|
||||
@ -213,6 +213,15 @@
|
||||
(:content (clipboard/html->payload
|
||||
(str "<head><meta charset=\"UTF-8\"></head>" safari "Hello")))))))))
|
||||
|
||||
(t/deftest payload-geometry-round-trip
|
||||
(let [content (text-content gradient-span)
|
||||
geometry {:origins [[{:x 12.5 :y 0.2 :box {:width 200 :height 40}}]]}
|
||||
hidden (clipboard/payload->html content file-id team-id geometry)]
|
||||
(t/testing "the payload keeps where the copied spans sat in their text box"
|
||||
(t/is (= geometry (:geometry (clipboard/html->payload hidden)))))
|
||||
(t/testing "the content stays as copied"
|
||||
(t/is (= content (:content (clipboard/html->payload hidden)))))))
|
||||
|
||||
(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>")))
|
||||
|
||||
@ -3,7 +3,7 @@ use crate::shapes::{vertical_align_offset, Shape, TextContent, Type};
|
||||
use crate::state::{TextEditorState, TextSelection};
|
||||
use crate::view::Viewbox;
|
||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||
use skia_safe::{BlendMode, Canvas, Color, Paint, Rect};
|
||||
use skia_safe::{BlendMode, Canvas, Color, Paint, Point, Rect};
|
||||
|
||||
pub fn render_overlay(
|
||||
canvas: &Canvas,
|
||||
@ -228,10 +228,40 @@ fn calculate_selection_rects(
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> Vec<Rect> {
|
||||
let mut rects = Vec::new();
|
||||
|
||||
let start = selection.start();
|
||||
let end = selection.end();
|
||||
range_rects(
|
||||
(start.paragraph, start.offset),
|
||||
(end.paragraph, end.offset),
|
||||
text_content,
|
||||
shape,
|
||||
)
|
||||
}
|
||||
|
||||
/// Top-left corner of the first line box of the text between `start` and `end`
|
||||
/// (paragraph, char offset), shape-local; `None` when the range covers no text.
|
||||
pub(crate) fn range_origin(
|
||||
start: (usize, usize),
|
||||
end: (usize, usize),
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> Option<Point> {
|
||||
range_rects(start, end, text_content, shape)
|
||||
.first()
|
||||
.map(|rect| Point::new(rect.left(), rect.top()))
|
||||
}
|
||||
|
||||
/// Line boxes, shape-local, of the text between `start` and `end` (paragraph,
|
||||
/// char offset).
|
||||
fn range_rects(
|
||||
start: (usize, usize),
|
||||
end: (usize, usize),
|
||||
text_content: &TextContent,
|
||||
shape: &Shape,
|
||||
) -> Vec<Rect> {
|
||||
let mut rects = Vec::new();
|
||||
let (start_paragraph, start_offset) = start;
|
||||
let (end_paragraph, end_offset) = end;
|
||||
|
||||
let paragraphs = text_content.paragraphs();
|
||||
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
|
||||
@ -242,7 +272,7 @@ fn calculate_selection_rects(
|
||||
let para_height = laid_out_para.height();
|
||||
|
||||
// Check if this paragraph is in selection range
|
||||
if para_idx < start.paragraph || para_idx > end.paragraph {
|
||||
if para_idx < start_paragraph || para_idx > end_paragraph {
|
||||
y_offset += para_height;
|
||||
continue;
|
||||
}
|
||||
@ -255,14 +285,14 @@ fn calculate_selection_rects(
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum();
|
||||
|
||||
let range_start = if para_idx == start.paragraph {
|
||||
start.offset
|
||||
let range_start = if para_idx == start_paragraph {
|
||||
start_offset
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let range_end = if para_idx == end.paragraph {
|
||||
end.offset
|
||||
let range_end = if para_idx == end_paragraph {
|
||||
end_offset
|
||||
} else {
|
||||
para_char_count
|
||||
};
|
||||
@ -291,3 +321,91 @@ fn calculate_selection_rects(
|
||||
|
||||
rects
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::globals::TestRenderResourcesGuard;
|
||||
use crate::render::RenderResources;
|
||||
use crate::shapes::{
|
||||
FontFamily, FontStyle, GrowType, Paragraph, TextAlign, TextDirection, TextSpan,
|
||||
};
|
||||
use crate::uuid::Uuid;
|
||||
|
||||
/// A laid-out fixed text, `width` wide, with a single span of `text`.
|
||||
fn text_shape(text: &str, width: f32) -> Shape {
|
||||
let span = TextSpan::new(
|
||||
text.to_string(),
|
||||
FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
|
||||
14.0,
|
||||
1.2,
|
||||
0.0,
|
||||
None,
|
||||
None,
|
||||
TextDirection::LTR,
|
||||
400,
|
||||
Uuid::nil(),
|
||||
vec![],
|
||||
);
|
||||
let mut content =
|
||||
TextContent::new(Rect::from_xywh(0.0, 0.0, width, 100.0), GrowType::Fixed);
|
||||
content.add_paragraph(Paragraph::new(
|
||||
TextAlign::Left,
|
||||
TextDirection::LTR,
|
||||
None,
|
||||
None,
|
||||
1.2,
|
||||
0.0,
|
||||
vec![span],
|
||||
));
|
||||
|
||||
let mut shape = Shape::new(Uuid::nil());
|
||||
shape.set_shape_type(Type::Text(content));
|
||||
shape.set_selrect(0.0, 0.0, width, 100.0);
|
||||
shape
|
||||
}
|
||||
|
||||
fn origin_of(shape: &Shape, start: usize, end: usize) -> Option<Point> {
|
||||
let Type::Text(content) = &shape.shape_type else {
|
||||
unreachable!()
|
||||
};
|
||||
range_origin((0, start), (0, end), content, shape)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_origin_is_where_the_text_starts() {
|
||||
let mut resources = RenderResources::try_new_headless().expect("headless resources");
|
||||
let _guard = TestRenderResourcesGuard::install(&mut resources);
|
||||
let shape = text_shape("hello world", 500.0);
|
||||
|
||||
let hello = origin_of(&shape, 0, 5).expect("hello has text");
|
||||
let world = origin_of(&shape, 6, 11).expect("world has text");
|
||||
|
||||
assert!(hello.x.abs() < 0.01, "{hello:?}");
|
||||
assert!((0.0..1.0).contains(&hello.y), "{hello:?}");
|
||||
assert!(world.x > hello.x, "{world:?}");
|
||||
assert!((world.y - hello.y).abs() < 0.01, "{world:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_origin_of_wrapped_text_is_on_its_first_line() {
|
||||
let mut resources = RenderResources::try_new_headless().expect("headless resources");
|
||||
let _guard = TestRenderResourcesGuard::install(&mut resources);
|
||||
// Too narrow for both words on one line.
|
||||
let shape = text_shape("hello world", 45.0);
|
||||
|
||||
let world = origin_of(&shape, 6, 11).expect("world has text");
|
||||
|
||||
assert!(world.x.abs() < 0.01, "{world:?}");
|
||||
assert!(world.y > 10.0, "{world:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_origin_is_none_for_an_empty_range() {
|
||||
let mut resources = RenderResources::try_new_headless().expect("headless resources");
|
||||
let _guard = TestRenderResourcesGuard::install(&mut resources);
|
||||
let shape = text_shape("hello", 500.0);
|
||||
|
||||
assert!(origin_of(&shape, 2, 2).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@ -424,6 +424,9 @@ pub struct TextContent {
|
||||
/// into cached Skia paragraphs. Kept across move clones so paint can
|
||||
/// translate glyphs + shaders together. See `cached_layout_paint_offset`.
|
||||
layout_paint_origin: Option<Point>,
|
||||
/// Box size the gradient and image shaders were built for. They are relative
|
||||
/// to the whole box, so a new size must rebuild them.
|
||||
layout_paint_size: Option<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl PartialEq for TextContent {
|
||||
@ -448,6 +451,7 @@ impl TextContent {
|
||||
layout_version: 0,
|
||||
layout_width: None,
|
||||
layout_paint_origin: None,
|
||||
layout_paint_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -464,6 +468,7 @@ impl TextContent {
|
||||
layout_version: 0,
|
||||
layout_width: None,
|
||||
layout_paint_origin: None,
|
||||
layout_paint_size: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1114,7 +1119,28 @@ impl TextContent {
|
||||
if self.layout.needs_update() || self.layout_version != self.content_version {
|
||||
return false;
|
||||
}
|
||||
self.layout_matches_paint_container(shape)
|
||||
self.layout_matches_paint_container(shape) && self.paint_size_matches(&shape.selrect())
|
||||
}
|
||||
|
||||
/// True when some span has a fill laid over the whole box (gradient or image).
|
||||
fn has_box_relative_fills(&self) -> bool {
|
||||
self.paragraphs.iter().any(|paragraph| {
|
||||
paragraph.children().iter().any(|span| {
|
||||
span.fills
|
||||
.iter()
|
||||
.any(|fill| !matches!(fill, shapes::Fill::Solid(_)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// True when the cached fill shaders fit a box of `selrect`'s size.
|
||||
fn paint_size_matches(&self, selrect: &Rect) -> bool {
|
||||
if !self.has_box_relative_fills() {
|
||||
return true;
|
||||
}
|
||||
self.layout_paint_size.is_some_and(|(width, height)| {
|
||||
(width - selrect.width()).abs() < 0.01 && (height - selrect.height()).abs() < 0.01
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn layout_cache_versions_match(&self) -> bool {
|
||||
@ -1156,11 +1182,13 @@ impl TextContent {
|
||||
// Paragraph paints (incl. absolute image/gradient shaders) were built
|
||||
// against `self.bounds()` in `paragraph_builder_group_from_text`.
|
||||
self.layout_paint_origin = Some(Point::new(self.bounds.x(), self.bounds.y()));
|
||||
self.layout_paint_size = Some((self.bounds.width(), self.bounds.height()));
|
||||
}
|
||||
|
||||
pub fn force_next_layout_update(&mut self) {
|
||||
self.layout_width = None;
|
||||
self.layout_paint_origin = None;
|
||||
self.layout_paint_size = None;
|
||||
self.layout.cached_extrect.set(None);
|
||||
// Bump the content version so update_layout can't early-return: auto-width
|
||||
// shapes always match their container and clearing the cache above doesn't
|
||||
@ -1182,6 +1210,7 @@ impl TextContent {
|
||||
if !self.layout.needs_update()
|
||||
&& self.layout_version == self.content_version
|
||||
&& layout_matches_container
|
||||
&& self.paint_size_matches(&selrect)
|
||||
{
|
||||
return self.size;
|
||||
}
|
||||
@ -1351,6 +1380,7 @@ impl Default for TextContent {
|
||||
layout_version: 0,
|
||||
layout_width: None,
|
||||
layout_paint_origin: None,
|
||||
layout_paint_size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1994,6 +2024,8 @@ pub fn calculate_position_data(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::globals::TestRenderResourcesGuard;
|
||||
use crate::render::RenderResources;
|
||||
|
||||
#[test]
|
||||
fn vertical_align_top_keeps_the_content_at_the_origin() {
|
||||
@ -2345,6 +2377,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn gradient_fill() -> shapes::Fill {
|
||||
shapes::Fill::LinearGradient(shapes::Gradient::new(
|
||||
(0.5, 0.0),
|
||||
(0.5, 1.0),
|
||||
255,
|
||||
1.0,
|
||||
&[(skia::Color::RED, 0.0), (skia::Color::BLUE, 1.0)],
|
||||
))
|
||||
}
|
||||
|
||||
fn solid_fill() -> shapes::Fill {
|
||||
shapes::Fill::Solid(shapes::SolidColor(skia::Color::RED))
|
||||
}
|
||||
|
||||
/// A laid-out text shape at 200x100 whose only span has `fills`.
|
||||
fn laid_out_text_shape(grow_type: GrowType, fills: Vec<shapes::Fill>) -> Shape {
|
||||
let mut paragraph = test_paragraph(&["hello"]);
|
||||
paragraph.children_mut()[0].fills = fills;
|
||||
let mut content = TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), grow_type);
|
||||
content.add_paragraph(paragraph);
|
||||
|
||||
let mut shape = Shape::new(Uuid::nil());
|
||||
shape.set_shape_type(shapes::Type::Text(content));
|
||||
shape.set_selrect(0.0, 0.0, 200.0, 100.0);
|
||||
shape
|
||||
}
|
||||
|
||||
/// Whether resizing `shape` to `width`x`height`, with no content change,
|
||||
/// rebuilt its cached Skia paragraphs.
|
||||
fn resize_rebuilds_paragraphs(shape: &mut Shape, width: f32, height: f32) -> bool {
|
||||
let before = Rc::clone(&shape.get_text_content().layout.paragraphs);
|
||||
assert!(!before.is_empty(), "the shape should be laid out");
|
||||
shape.set_selrect(0.0, 0.0, width, height);
|
||||
!Rc::ptr_eq(&before, &shape.get_text_content().layout.paragraphs)
|
||||
}
|
||||
|
||||
// Gradient and image shaders are built into the cached paragraphs against
|
||||
// the box size, so a new box size must rebuild them.
|
||||
|
||||
#[test]
|
||||
fn resizing_a_fixed_text_height_rebuilds_box_relative_fills() {
|
||||
let mut resources = RenderResources::try_new_headless().expect("headless resources");
|
||||
let _guard = TestRenderResourcesGuard::install(&mut resources);
|
||||
let mut shape = laid_out_text_shape(GrowType::Fixed, vec![gradient_fill()]);
|
||||
assert!(resize_rebuilds_paragraphs(&mut shape, 200.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_an_auto_width_text_rebuilds_box_relative_fills() {
|
||||
let mut resources = RenderResources::try_new_headless().expect("headless resources");
|
||||
let _guard = TestRenderResourcesGuard::install(&mut resources);
|
||||
let mut shape = laid_out_text_shape(GrowType::AutoWidth, vec![gradient_fill()]);
|
||||
assert!(resize_rebuilds_paragraphs(&mut shape, 400.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_a_fixed_text_height_keeps_the_cache_for_solid_fills() {
|
||||
let mut resources = RenderResources::try_new_headless().expect("headless resources");
|
||||
let _guard = TestRenderResourcesGuard::install(&mut resources);
|
||||
let mut shape = laid_out_text_shape(GrowType::Fixed, vec![solid_fill()]);
|
||||
assert!(!resize_rebuilds_paragraphs(&mut shape, 200.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_layout_paint_offset_tracks_selrect_move() {
|
||||
let mut content = sample_text_content();
|
||||
|
||||
@ -3,6 +3,7 @@ use macros::{wasm_error, ToJs};
|
||||
use crate::globals::{get_render_state, get_text_editor_state};
|
||||
use crate::math::{Matrix, Point};
|
||||
use crate::mem;
|
||||
use crate::render::text_editor::range_origin;
|
||||
use crate::shapes::{TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
|
||||
use crate::state::{State, TextEditorEvent, TextEditorState};
|
||||
use crate::utils::uuid_from_u32_quartet;
|
||||
@ -1032,3 +1033,56 @@ pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool {
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes where the text between the UTF-16 offsets starts in the text shape
|
||||
/// (x, y, shape-local) and its box size (width, height); false when no text.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub extern "C" fn text_get_range_origin(
|
||||
a: u32,
|
||||
b: u32,
|
||||
c: u32,
|
||||
d: u32,
|
||||
start_paragraph: u32,
|
||||
start_offset: u32,
|
||||
end_paragraph: u32,
|
||||
end_offset: u32,
|
||||
buffer_ptr: *mut f32,
|
||||
) -> Result<bool> {
|
||||
with_state!(state, {
|
||||
let Some(shape) = state.shapes.get(&uuid_from_u32_quartet(a, b, c, d)) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Type::Text(text_content) = &shape.shape_type else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let paragraphs = text_content.paragraphs();
|
||||
let to_char = |paragraph: u32, offset: u32| {
|
||||
let paragraph = paragraph as usize;
|
||||
let offset = paragraphs
|
||||
.get(paragraph)
|
||||
.map(|para| para.utf16_offset_to_char(offset as usize))
|
||||
.unwrap_or(offset as usize);
|
||||
(paragraph, offset)
|
||||
};
|
||||
let Some(origin) = range_origin(
|
||||
to_char(start_paragraph, start_offset),
|
||||
to_char(end_paragraph, end_offset),
|
||||
text_content,
|
||||
shape,
|
||||
) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let selrect = shape.selrect();
|
||||
|
||||
unsafe {
|
||||
*buffer_ptr = origin.x;
|
||||
*buffer_ptr.add(1) = origin.y;
|
||||
*buffer_ptr.add(2) = selrect.width();
|
||||
*buffer_ptr.add(3) = selrect.height();
|
||||
}
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user