penpot/frontend/src/app/render_wasm/text_editor.cljs

759 lines
32 KiB
Clojure

;; 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 INC Sucursal en España SL
(ns app.render-wasm.text-editor
"Text editor WASM bindings"
(:require
[app.common.types.fills.impl :as types.fills.impl]
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.main.fonts :as main-fonts]
[app.render-wasm.api.fonts :as fonts]
[app.render-wasm.helpers :as h]
[app.render-wasm.mem :as mem]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.wasm :as wasm]
[app.util.color :as uc]
[app.util.dom :as dom]))
(def multiple-state-multiple (sr/translate-multiple-state :multiple))
(def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 31 4))
(def ^:const TEXT_EDITOR_STYLES_FILL_SOLID 0)
(def ^:const TEXT_EDITOR_STYLES_FILL_LINEAR_GRADIENT 1)
(def ^:const TEXT_EDITOR_STYLES_FILL_RADIAL_GRADIENT 2)
(def ^:const TEXT_EDITOR_STYLES_FILL_IMAGE 3)
(defn- rgba->fill-color
[rgba]
(let [rgb (bit-and rgba 0x00ffffff)
hex (.toString rgb 16)]
(str "#" (.padStart hex 6 "0"))))
(defn- rgba->opacity
[rgba]
(let [alpha (bit-and (bit-shift-right rgba 24) 0xff)]
(/ (js/Math.round (* (/ alpha 255) 100)) 100)))
(defn- u8->opacity
[alpha]
(/ (js/Math.round (* (/ alpha 255) 100)) 100))
(defn- read-fill-from-heap
[heap-u8 heap-u32 heap-i32 heap-f32 fill-byte-offset]
(let [fill-type (aget heap-u8 fill-byte-offset)
fill-u32-offset (mem/->offset-32 fill-byte-offset)]
(case fill-type
TEXT_EDITOR_STYLES_FILL_SOLID
(let [rgba (aget heap-u32 (+ fill-u32-offset 1))]
{:fill-color (rgba->fill-color rgba)
:fill-opacity (rgba->opacity rgba)})
TEXT_EDITOR_STYLES_FILL_LINEAR_GRADIENT
(let [gradient-u32-offset (mem/->offset-32 (+ fill-byte-offset 4))
start-x (aget heap-f32 gradient-u32-offset)
start-y (aget heap-f32 (+ gradient-u32-offset 1))
end-x (aget heap-f32 (+ gradient-u32-offset 2))
end-y (aget heap-f32 (+ gradient-u32-offset 3))
alpha (aget heap-u8 (+ fill-byte-offset 20))
width (aget heap-f32 (+ gradient-u32-offset 5))
stop-count (aget heap-u8 (+ fill-byte-offset 28))
stops (->> (range stop-count)
(map (fn [idx]
(let [stop-offset (+ fill-byte-offset 32 (* idx 8))
stop-u32-offset (mem/->offset-32 stop-offset)
rgba (aget heap-u32 stop-u32-offset)
offset (aget heap-f32 (+ stop-u32-offset 1))]
{:color (rgba->fill-color rgba)
:opacity (rgba->opacity rgba)
:offset (/ (js/Math.round (* offset 100)) 100)})))
(into []))]
{:fill-opacity (u8->opacity alpha)
:fill-color-gradient {:start-x start-x
:start-y start-y
:end-x end-x
:end-y end-y
:width width
:stops stops
:type :linear}})
TEXT_EDITOR_STYLES_FILL_RADIAL_GRADIENT
(let [gradient-u32-offset (mem/->offset-32 (+ fill-byte-offset 4))
start-x (aget heap-f32 gradient-u32-offset)
start-y (aget heap-f32 (+ gradient-u32-offset 1))
end-x (aget heap-f32 (+ gradient-u32-offset 2))
end-y (aget heap-f32 (+ gradient-u32-offset 3))
alpha (aget heap-u8 (+ fill-byte-offset 20))
width (aget heap-f32 (+ gradient-u32-offset 5))
stop-count (aget heap-u8 (+ fill-byte-offset 28))
stops (->> (range stop-count)
(map (fn [idx]
(let [stop-offset (+ fill-byte-offset 32 (* idx 8))
stop-u32-offset (mem/->offset-32 stop-offset)
rgba (aget heap-u32 stop-u32-offset)
offset (aget heap-f32 (+ stop-u32-offset 1))]
{:color (rgba->fill-color rgba)
:opacity (rgba->opacity rgba)
:offset (/ (js/Math.round (* offset 100)) 100)})))
(into []))]
{:fill-opacity (u8->opacity alpha)
:fill-color-gradient {:start-x start-x
:start-y start-y
:end-x end-x
:end-y end-y
:width width
:stops stops
:type :radial}})
TEXT_EDITOR_STYLES_FILL_IMAGE
(let [a (aget heap-u32 (+ fill-u32-offset 1))
b (aget heap-u32 (+ fill-u32-offset 2))
c (aget heap-u32 (+ fill-u32-offset 3))
d (aget heap-u32 (+ fill-u32-offset 4))
alpha (aget heap-u8 (+ fill-byte-offset 20))
flags (aget heap-u8 (+ fill-byte-offset 21))
width (aget heap-i32 (+ fill-u32-offset 6))
height (aget heap-i32 (+ fill-u32-offset 7))]
{:fill-opacity (u8->opacity alpha)
:fill-image {:id (uuid/from-unsigned-parts a b c d)
:width width
:height height
:keep-aspect-ratio (not (zero? (bit-and flags 0x01)))
:name "sample"}})
nil)))
(def ^:private selection-color-css-var "--text-editor-selection-background-color")
(defn- resolve-theme-color
"Resolve a themed CSS color variable (read from the document body) into a
32-bit argb value for the WASM text editor, preserving the variable's alpha
channel."
[css-var]
(when-let [{:keys [color opacity]}
(uc/parse-css-color-opacity
(dom/get-css-variable css-var js/document.body))]
(sr-clr/hex->u32argb color opacity)))
;; ARGB u32 for opaque white, painted with a Difference blend mode so the caret
;; always shows the inverted color of the background.
(def ^:private caret-invert-color 0xffffffff)
(defn text-editor-apply-theme
"Push the current theme's selection color (read from the CSS custom properties
on the document body) into the WASM text editor, together with the default
caret: white with invert, so it shows the inverted color of the background.
The caret only switches to a solid text color (invert off) via
`text-editor-apply-caret-color`. The editor theme is a persistent singleton,
so call once after init and again on every color-scheme change."
[]
(when wasm/context-initialized?
(let [selection (resolve-theme-color selection-color-css-var)]
(when selection
(h/call wasm/internal-module "_text_editor_apply_theme" selection caret-invert-color true)))))
(defn- solid-fill?
[fill]
(some? (:fill-color fill)))
(defn resolve-caret-color
"Compute the caret color from the text `fills` at the caret (as returned by
`text-editor-get-current-styles`), as `{:color <argb-u32> :invert? bool}`:
- when there is at least one solid fill, match the topmost (visible) one,
painted normally (`:invert? false`);
- otherwise (no fill, gradient, image fills, mixed selection, …) use white
with `:invert? true`, which the renderer paints with a Difference blend so
the caret is the inverted color of whatever is behind it."
[fills]
(if-let [solid (and (sequential? fills)
(some #(when (solid-fill? %) %) fills))]
{:color (sr-clr/hex->u32argb (:fill-color solid) (:fill-opacity solid))
:invert? false}
{:color caret-invert-color
:invert? true}))
(defn text-editor-apply-caret-color
"Update the WASM text-editor caret color so it matches the text at the caret
(see `resolve-caret-color`). Re-applies the current theme selection color
unchanged, since the WASM theme is a singleton holding both."
[fills]
(when wasm/context-initialized?
(let [selection (resolve-theme-color selection-color-css-var)
{:keys [color invert?]} (resolve-caret-color fills)]
(when selection
(h/call wasm/internal-module "_text_editor_apply_theme" selection color invert?)))))
(defn text-editor-focus
[id]
(when (wasm/ready?)
(let [buffer (uuid/get-u32 id)]
(when-not (h/call wasm/internal-module "_text_editor_focus"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))
(throw (js/Error. "TextEditor focus failed"))))))
(defn text-editor-set-cursor-from-offset
"Sets caret position from shape relative coordinates"
[{:keys [x y]}]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_set_cursor_from_offset" x y)))
(defn text-editor-set-cursor-from-point
"Sets caret position from screen (canvas) coordinates"
[{:keys [x y]}]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
(defn text-editor-toggle-overtype-mode
"Toggles overtype mode"
[]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_toggle_overtype_mode")))
(defn text-editor-pointer-down
[{:keys [x y]}]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_pointer_down" x y)))
(defn text-editor-pointer-move
[{:keys [x y]}]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_pointer_move" x y)))
(defn text-editor-pointer-up
[{:keys [x y]}]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_pointer_up" x y)))
(defn text-editor-update-blink
[timestamp-ms]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms)))
(defn text-editor-render-overlay
[]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_render_overlay")))
(defn text-editor-render-caret
"Re-compose the frame from the Backbuffer (the last full render) and draw the
caret/selection overlay on top, submitting one atomic frame. Pixel identical
to the last full render at any zoom, so the blink does not flash."
[]
(when wasm/context-initialized?
(h/call wasm/internal-module "_text_editor_render_caret")))
(defn text-editor-poll-event
[]
(when (wasm/ready?)
(let [res (h/call wasm/internal-module "_text_editor_poll_event")]
res)))
(defn- text-editor-get-style-property
([state value]
(text-editor-get-style-property state value value))
([state value default-value]
(case state
0 default-value
1 value
2 :multiple
0)))
(defn- text-editor-compute-font-variant-id
[font-id font-weight font-style]
(let [font-data (main-fonts/get-font-data font-id)
variant (main-fonts/find-closest-variant font-data font-weight font-style)]
(or (:id variant)
(:name variant)
"regular")))
(defn text-editor-get-current-styles
[]
(when (wasm/ready?)
(let [ptr (h/call wasm/internal-module "_text_editor_get_current_styles")]
(when (and ptr (not (zero? ptr)))
(let [heap-u8 (mem/get-heap-u8)
heap-u32 (mem/get-heap-u32)
heap-i32 (mem/get-heap-i32)
heap-f32 (mem/get-heap-f32)
u32-offset (mem/->offset-32 ptr)
vertical-align (aget heap-u32 u32-offset)
text-align-state (aget heap-u32 (+ u32-offset 1))
text-direction-state (aget heap-u32 (+ u32-offset 2))
text-decoration-state (aget heap-u32 (+ u32-offset 3))
text-transform-state (aget heap-u32 (+ u32-offset 4))
font-family-id-state (aget heap-u32 (+ u32-offset 5))
font-size-state (aget heap-u32 (+ u32-offset 6))
font-weight-state (aget heap-u32 (+ u32-offset 7))
;; Unused: the variant id is stored as a zero uuid for every span
_font-variant-id-state (aget heap-u32 (+ u32-offset 8))
line-height-state (aget heap-u32 (+ u32-offset 9))
letter-spacing-state (aget heap-u32 (+ u32-offset 10))
font-style-state (aget heap-u32 (+ u32-offset 11))
num-fills (aget heap-u32 (+ u32-offset 12))
multiple-fills (aget heap-u32 (+ u32-offset 13))
text-align-value (aget heap-u32 (+ u32-offset 14))
text-direction-value (aget heap-u32 (+ u32-offset 15))
text-decoration-value (aget heap-u32 (+ u32-offset 16))
text-transform-value (aget heap-u32 (+ u32-offset 17))
font-family-id-a (aget heap-u32 (+ u32-offset 18))
font-family-id-b (aget heap-u32 (+ u32-offset 19))
font-family-id-c (aget heap-u32 (+ u32-offset 20))
font-family-id-d (aget heap-u32 (+ u32-offset 21))
font-family-id-value (uuid/from-unsigned-parts font-family-id-a font-family-id-b font-family-id-c font-family-id-d)
font-style-raw-value (aget heap-u32 (+ u32-offset 22))
font-size-value (aget heap-f32 (+ u32-offset 23))
font-weight-value (aget heap-i32 (+ u32-offset 24))
line-height-value (aget heap-f32 (+ u32-offset 29))
letter-spacing-value (aget heap-f32 (+ u32-offset 30))
font-id (fonts/uuid->font-id font-family-id-value)
font-style-value (sr/untranslate-font-style (text-editor-get-style-property font-style-state font-style-raw-value))
font-variant-id-computed (text-editor-compute-font-variant-id font-id font-weight-value font-style-value)
;; A font variant is defined by its family + weight + style, so it
;; is "mixed" when any of those is mixed. When the family itself is
;; mixed there is no single font to resolve variants against, so we
;; also report the variant as mixed.
font-variant-multiple? (or (= font-family-id-state multiple-state-multiple)
(= font-weight-state multiple-state-multiple)
(= font-style-state multiple-state-multiple))
fills (->> (range num-fills)
(map (fn [idx]
(read-fill-from-heap
heap-u8 heap-u32 heap-i32 heap-f32
(+ ptr
TEXT_EDITOR_STYLES_METADATA_SIZE
(* idx types.fills.impl/FILL-U8-SIZE)))))
(filter some?)
(into []))
;; The order of these two variables is important, do not
;; reorder them.
selected-colors (if (= multiple-fills 1) fills nil)
fills (if (= multiple-fills 1) :multiple fills)
result {:vertical-align (sr/untranslate-vertical-align vertical-align)
:text-align (sr/untranslate-text-align (text-editor-get-style-property text-align-state text-align-value))
:text-direction (sr/untranslate-text-direction (text-editor-get-style-property text-direction-state text-direction-value))
:text-decoration (sr/untranslate-text-decoration (text-editor-get-style-property text-decoration-state text-decoration-value))
:text-transform (sr/untranslate-text-transform (text-editor-get-style-property text-transform-state text-transform-value))
:line-height (text-editor-get-style-property line-height-state line-height-value)
:letter-spacing (text-editor-get-style-property letter-spacing-state letter-spacing-value)
:font-size (text-editor-get-style-property font-size-state font-size-value)
:font-weight (text-editor-get-style-property font-weight-state font-weight-value)
:font-style font-style-value
:font-family (text-editor-get-style-property font-family-id-state font-id)
:font-id (text-editor-get-style-property font-family-id-state font-id)
:font-variant-id (if font-variant-multiple? :multiple font-variant-id-computed)
:typography-ref-file nil
:typography-ref-id nil
:selected-colors selected-colors
:fills fills}]
(mem/free)
result)))))
(defn text-editor-encode-text-pre
[text]
(when (and (not (empty? text))
(wasm/ready?))
(let [encoder (js/TextEncoder.)
buf (.encode encoder text)
heapu8 (mem/get-heap-u8)
size (mem/size buf)
offset (mem/alloc size)]
(mem/write-buffer offset heapu8 buf))))
(defn text-editor-encode-text-post
[text]
(when (and (not (empty? text))
(wasm/ready?))
(mem/free)))
(defn text-editor-composition-start
[]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_composition_start")))
(defn text-editor-composition-update
[text]
(when (wasm/ready?)
(text-editor-encode-text-pre text)
(h/call wasm/internal-module "_text_editor_composition_update")
(text-editor-encode-text-post text)))
(defn text-editor-composition-end
[text]
(when (wasm/ready?)
(text-editor-encode-text-pre text)
(h/call wasm/internal-module "_text_editor_composition_end")
(text-editor-encode-text-post text)))
(defn text-editor-insert-text
[text]
(when (wasm/ready?)
(text-editor-encode-text-pre text)
(h/call wasm/internal-module "_text_editor_insert_text")
(text-editor-encode-text-post text)))
(defn text-editor-delete-backward
([]
(text-editor-delete-backward false))
([word-boundary]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_delete_backward" word-boundary))))
(defn text-editor-delete-forward
([]
(text-editor-delete-forward false))
([word-boundary]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_delete_forward" word-boundary))))
(defn text-editor-insert-paragraph []
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
(defn text-editor-move-cursor
[direction word-boundary extend-selection]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_move_cursor" direction word-boundary (if extend-selection 1 0))))
(defn text-editor-select-all
[]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_select_all")))
(defn text-editor-select-word-boundary
[{:keys [x y]}]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_select_word_boundary" x y)))
(defn text-editor-blur
[]
(when (wasm/ready?)
(when-not (h/call wasm/internal-module "_text_editor_blur")
(throw (js/Error. "TextEditor blur failed")))))
(defn text-editor-dispose
[]
(when (wasm/ready?)
(h/call wasm/internal-module "_text_editor_dispose")))
(defn text-editor-has-focus?
([id]
(when (wasm/ready?)
(not (zero? (h/call wasm/internal-module "_text_editor_has_focus_with_id" id)))))
([]
(when (wasm/ready?)
(not (zero? (h/call wasm/internal-module "_text_editor_has_focus"))))))
(defn text-editor-has-selection?
([]
(when (wasm/ready?)
(not (zero? (h/call wasm/internal-module "_text_editor_has_selection"))))))
(defn text-editor-export-content
[]
(when (wasm/ready?)
(let [ptr (h/call wasm/internal-module "_text_editor_export_content")]
(when (and ptr (not (zero? ptr)))
(let [json-str (mem/read-string ptr)]
(mem/free)
(js/JSON.parse json-str))))))
(defn text-editor-export-selection
"Export only the currently selected text as plain text from the WASM editor. Requires WASM support (_text_editor_export_selection)."
[]
(when (wasm/ready?)
(let [ptr (h/call wasm/internal-module "_text_editor_export_selection")]
(when (and ptr (not (zero? ptr)))
(let [text (mem/read-string ptr)]
(mem/free)
text)))))
(defn text-editor-get-active-shape-id
[]
(when (wasm/ready?)
(try
(let [byte-offset (mem/alloc 16)
u32-offset (mem/->offset-32 byte-offset)
heap (mem/get-heap-u32)]
(h/call wasm/internal-module "_text_editor_get_active_shape_id" byte-offset)
(let [a (aget heap u32-offset)
b (aget heap (+ u32-offset 1))
c (aget heap (+ u32-offset 2))
d (aget heap (+ u32-offset 3))
result (when (or (not= a 0) (not= b 0) (not= c 0) (not= d 0))
(uuid/from-unsigned-parts a b c d))]
(mem/free)
result))
(catch js/Error e
(js/console.error "[text-editor-get-active-shape-id] Error:" e)
nil))))
(defn text-editor-get-selection
[]
(when (wasm/ready?)
(let [byte-offset (mem/alloc 16)
u32-offset (mem/->offset-32 byte-offset)
heap (mem/get-heap-u32)
has-selection? (h/call wasm/internal-module "_text_editor_get_selection" byte-offset)]
(if has-selection?
(let [result {:anchor-para (aget heap u32-offset)
:anchor-offset (aget heap (+ u32-offset 1))
:focus-para (aget heap (+ u32-offset 2))
:focus-offset (aget heap (+ u32-offset 3))}]
(mem/free)
result)
(do
(mem/free)
nil)))))
;; This is used as a intermediate cache between Clojure global state and WASM state.
(def ^:private shape-text-contents (atom {}))
(defn cache-shape-text-content!
[shape-id content]
(when (some? content)
(swap! shape-text-contents assoc shape-id content)))
(defn get-cached-content
[shape-id]
(get @shape-text-contents shape-id))
(defn update-cached-content!
[shape-id content]
(swap! shape-text-contents assoc shape-id content))
(defn- merge-exported-texts-into-content
"Merge exported span texts back into the existing content tree.
The WASM editor may split or merge paragraphs (Enter / Backspace at
paragraph boundary), so the exported structure can differ from the
original. When extra paragraphs or spans appear we clone styling from
the nearest existing sibling; when fewer appear we truncate.
exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]]
content existing Penpot content map (root -> paragraph-set -> …)"
[content exported-texts]
(let [para-set (first (get content :children))
orig-paras (get para-set :children)
num-orig (count orig-paras)
last-orig-para (when (seq orig-paras) (last orig-paras))
template-span (when last-orig-para
(-> last-orig-para :children last))
new-paras
(mapv (fn [para-idx exported-span-texts]
(let [orig-para (if (< para-idx num-orig)
(nth orig-paras para-idx)
(dissoc last-orig-para :children))
orig-spans (get orig-para :children)
num-orig-spans (count orig-spans)
last-orig-span (when (seq orig-spans) (last orig-spans))]
(assoc orig-para :children
(mapv (fn [span-idx new-text]
(let [orig-span (if (< span-idx num-orig-spans)
(nth orig-spans span-idx)
(or last-orig-span template-span))]
(assoc orig-span :text new-text)))
(range (count exported-span-texts))
exported-span-texts))))
(range (count exported-texts))
exported-texts)
new-para-set (assoc para-set :children new-paras)]
(assoc content :children [new-para-set])))
(defn- default-empty-text-content
"Build a default, empty text content tree used as a merge template.
A text shape created by a single click starts with `:content` nil, so
`set-shape-text-content` never seeds the content cache for it. Without a
template `text-editor-sync-content` would bail and the characters typed into
the WASM editor would never reach the shape. This provides the default
(Source Sans Pro) styling the WASM editor uses for a fresh empty shape."
[]
(let [attrs (txt/get-default-text-attrs)]
{:type "root"
:children [{:type "paragraph-set"
:children [(merge attrs
{:type "paragraph"
:children [(merge attrs {:text ""})]})]}]}))
(defn text-editor-sync-content
"Sync text content from the WASM text editor back to the frontend shape.
Exports the current span texts from WASM, merges them into the shape's
cached content tree (preserving per-span styling), and returns the
shape-id and the fully merged content map ready for
v2-update-text-shape-content."
[]
(when (and (wasm/ready?) (text-editor-has-focus?))
(let [shape-id (text-editor-get-active-shape-id)
new-texts (text-editor-export-content)]
(when (and shape-id new-texts)
(let [texts-clj (js->clj new-texts)
;; A brand-new empty text shape (single click) has no cached
;; content yet, so fall back to a default template so the first
;; keystrokes are synced back to the shape instead of dropped.
content (or (get-cached-content shape-id)
(default-empty-text-content))]
(when content
(let [merged (merge-exported-texts-into-content content texts-clj)]
(swap! shape-text-contents assoc shape-id merged)
{:shape-id shape-id
:content merged})))))))
(defn- normalize-selection
"Given anchor/focus para+offset, return {:start-para :start-offset :end-para :end-offset}
ordered so start <= end."
[{:keys [anchor-para anchor-offset focus-para focus-offset]}]
(if (or (< anchor-para focus-para)
(and (= anchor-para focus-para) (<= anchor-offset focus-offset)))
{:start-para anchor-para :start-offset anchor-offset
:end-para focus-para :end-offset focus-offset}
{:start-para focus-para :start-offset focus-offset
:end-para anchor-para :end-offset anchor-offset}))
(defn apply-attrs-to-paragraph
"Apply `styles` (attrs map, or a fn per span) within [sel-start, sel-end), splitting spans."
[para sel-start sel-end styles]
(let [spans (:children para)
result (loop [spans spans
pos 0
acc []]
(if (empty? spans)
acc
(let [span (first spans)
text (:text span)
span-len (count text)
span-end (+ pos span-len)
ol-start (max pos sel-start)
ol-end (min span-end sel-end)
has-overlap? (< ol-start ol-end)]
(if (not has-overlap?)
(recur (rest spans) span-end (conj acc span))
(let [before (when (> ol-start pos)
(assoc span :text (subs text 0 (- ol-start pos))))
selected (-> (if (fn? styles)
(styles span)
(merge span styles))
(assoc :text (subs text (- ol-start pos) (- ol-end pos))))
after (when (< ol-end span-end)
(assoc span :text (subs text (- ol-end pos))))]
(recur (rest spans) span-end
(-> acc
(into (keep identity [before selected after])))))))))]
(assoc para :children result)))
(defn- para-char-count
[para]
(apply + (map (fn [span] (count (:text span))) (:children para))))
(defn- paragraph-selected-spans
"Return the spans of `para` that overlap the [sel-start, sel-end) char range."
[para sel-start sel-end]
(loop [spans (:children para)
pos 0
acc []]
(if (empty? spans)
acc
(let [span (first spans)
span-end (+ pos (count (:text span)))
overlap? (< (max pos sel-start) (min span-end sel-end))]
(recur (rest spans) span-end (cond-> acc overlap? (conj span)))))))
(defn selection-fills
"The selection's fills: shared vector if all spans match, `:multiple` if not, nil if empty."
[content {:keys [start-para start-offset end-para end-offset]}]
(let [paragraphs (:children (first (:children content)))
selected (mapcat (fn [idx para]
(cond
(or (< idx start-para) (> idx end-para)) nil
(= start-para end-para) (paragraph-selected-spans para start-offset end-offset)
(= idx start-para) (paragraph-selected-spans para start-offset (para-char-count para))
(= idx end-para) (paragraph-selected-spans para 0 end-offset)
:else (paragraph-selected-spans para 0 (para-char-count para))))
(range (count paragraphs))
paragraphs)
fills-set (into #{} (map :fills) selected)]
(cond
(empty? selected) nil
(= 1 (count fills-set)) (first fills-set)
:else :multiple)))
(defn apply-styles-to-selection
"Apply `styles` (attrs map, or a fn per span) to the selected spans; `:with-fills?` also returns `:fills`."
[styles use-shape-fn set-shape-text-content-fn & [{:keys [with-fills?]}]]
(when (wasm/ready?)
(let [;; Drop nil-valued attrs so they are never merged onto text spans.
;; The DOM editor path strips these in `attrs->styles`; the WASM merge
;; here (`apply-attrs-to-paragraph`) does not, so an unresolved attr
;; (e.g. nil :font-family/:font-weight/:font-style from an unloaded
;; font) would corrupt the span and fail the backend schema.
styles (if (fn? styles)
styles
(into {} (remove (comp nil? val)) styles))
shape-id (text-editor-get-active-shape-id)
selection (text-editor-get-selection)]
(when (and shape-id selection)
(let [content (get-cached-content shape-id)]
(when content
(let [normalized-selection (normalize-selection selection)
{:keys [start-para start-offset end-para end-offset]} normalized-selection
collapsed? (and (= start-para end-para) (= start-offset end-offset))
paragraph-set (first (:children content))
paragraphs (:children paragraph-set)
new-paragraphs
(when (not collapsed?)
(mapv (fn [idx para]
(cond
;; paragraph outside the range of paragraphs.
(or (< idx start-para) (> idx end-para))
para
;; same paragraph.
(= start-para end-para)
(apply-attrs-to-paragraph para start-offset end-offset styles)
;; first paragraph
(= idx start-para)
(apply-attrs-to-paragraph para start-offset (para-char-count para) styles)
;; final paragraph
(= idx end-para)
(apply-attrs-to-paragraph para 0 end-offset styles)
;; any other paragraph
:else
(apply-attrs-to-paragraph para 0 (para-char-count para) styles)))
(range (count paragraphs))
paragraphs))
new-content (when new-paragraphs
(assoc content :children
[(assoc paragraph-set :children new-paragraphs)]))]
(when new-content
(update-cached-content! shape-id new-content)
(use-shape-fn shape-id)
(set-shape-text-content-fn shape-id new-content)
(cond-> {:shape-id shape-id
:content new-content}
with-fills?
(assoc :fills (selection-fills new-content normalized-selection)))))))))))