diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 557747d080..4796ab969c 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -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)))))))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 0acbe4e92f..8ac273e1d2 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -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))))) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index c7e529ec6f..4348117ed8 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -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 diff --git a/frontend/src/app/render_wasm/text_paste.cljs b/frontend/src/app/render_wasm/text_paste.cljs index dbdd6cffec..4520327ce6 100644 --- a/frontend/src/app/render_wasm/text_paste.cljs +++ b/frontend/src/app/render_wasm/text_paste.cljs @@ -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))) diff --git a/frontend/src/app/util/text/clipboard.cljs b/frontend/src/app/util/text/clipboard.cljs index f0f76bee6b..35df340afe 100644 --- a/frontend/src/app/util/text/clipboard.cljs +++ b/frontend/src/app/util/text/clipboard.cljs @@ -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 ""))) + "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 "")))) (defn html->payload "The Penpot payload in `html`, or nil when it has none." diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 9fe41428e7..eac8693cff 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -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)))))) 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 index 675b1ea18a..6b6445f362 100644 --- 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 @@ -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}))))) 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 795309aff3..c9bc145647 100644 --- a/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/text_paste_test.cljs @@ -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)))))) diff --git a/frontend/test/frontend_tests/util_text_clipboard_test.cljs b/frontend/test/frontend_tests/util_text_clipboard_test.cljs index c0bcdbd07f..e5534a1517 100644 --- a/frontend/test/frontend_tests/util_text_clipboard_test.cljs +++ b/frontend/test/frontend_tests/util_text_clipboard_test.cljs @@ -213,6 +213,15 @@ (:content (clipboard/html->payload (str "
" 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 "Hello
"))) diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 7adfa5d692..fdf9adb037 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -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