mirror of
https://github.com/penpot/penpot.git
synced 2026-11-05 01:20:38 +00:00
1096 lines
47 KiB
Clojure
1096 lines
47 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 SUBSIDIARY SL
|
|
|
|
(ns app.render-wasm.text-editor
|
|
"Text editor WASM bindings"
|
|
(:require
|
|
[app.common.render-wasm.helpers :as h]
|
|
[app.common.render-wasm.mem :as mem]
|
|
[app.common.render-wasm.serializers :as sr]
|
|
[app.common.render-wasm.serializers.color :as sr-clr]
|
|
[app.common.render-wasm.wasm :as wasm]
|
|
[app.common.types.fills.impl :as types.fills.impl]
|
|
[app.common.types.text :as txt]
|
|
[app.common.types.text.cjk-layout :as cjk]
|
|
[app.common.uuid :as uuid]
|
|
[app.main.fonts :as main-fonts]
|
|
;; Required for side effects: binds the generated enums.
|
|
[app.render-wasm.api.enums]
|
|
[app.render-wasm.api.fonts :as fonts]
|
|
[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))
|
|
mtype (bit-or (aget heap-u8 (+ fill-byte-offset 22))
|
|
(bit-shift-left (aget heap-u8 (+ fill-byte-offset 23)) 8))
|
|
width (aget heap-i32 (+ fill-u32-offset 6))
|
|
height (aget heap-i32 (+ fill-u32-offset 7))]
|
|
{:fill-opacity (u8->opacity alpha)
|
|
:fill-image (cond-> {:id (uuid/from-unsigned-parts a b c d)
|
|
:width width
|
|
:height height
|
|
:mtype (types.fills.impl/decode-mtype mtype)
|
|
:keep-aspect-ratio (not (zero? (bit-and flags 0x01)))
|
|
:name "sample"}
|
|
(not (zero? (bit-and flags 0x02)))
|
|
(assoc :transform {:x (aget heap-f32 (+ fill-u32-offset 8))
|
|
:y (aget heap-f32 (+ fill-u32-offset 9))
|
|
:width (aget heap-f32 (+ fill-u32-offset 10))
|
|
:height (aget heap-f32 (+ fill-u32-offset 11))}))})
|
|
|
|
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-down-extend
|
|
"Extends the selection up to the pointer instead of collapsing the caret."
|
|
[{:keys [x y]}]
|
|
(when (wasm/ready?)
|
|
(h/call wasm/internal-module "_text_editor_pointer_down_extend" 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-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/ready?)
|
|
(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))
|
|
;; WASM reports size/weight as numbers, but the rest of Penpot (and the backend schema) expects strings.
|
|
:line-height (let [height (text-editor-get-style-property line-height-state line-height-value)]
|
|
(if (= height :multiple) height (str height)))
|
|
:letter-spacing (let [spacing (text-editor-get-style-property letter-spacing-state letter-spacing-value)]
|
|
(if (= spacing :multiple) spacing (str spacing)))
|
|
:font-size (let [size (text-editor-get-style-property font-size-state font-size-value)]
|
|
(if (= size :multiple) size (str size)))
|
|
:font-weight (let [weight (text-editor-get-style-property font-weight-state font-weight-value)]
|
|
(if (= weight :multiple) weight (str weight)))
|
|
: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-get-cursor-rect
|
|
"Read the caret rectangle from the WASM editor.
|
|
|
|
Coordinates are in the shape's local selrect space (the same space as the
|
|
text overlay's foreignObject). Returns a map with :x :y :width :height, or
|
|
nil when there is no active caret."
|
|
[]
|
|
(when (wasm/ready?)
|
|
(let [ptr (h/call wasm/internal-module "_text_editor_get_cursor_rect")]
|
|
(when (and ptr (not (zero? ptr)))
|
|
(let [heap-f32 (mem/get-heap-f32)
|
|
offset (mem/->offset-32 ptr)
|
|
x (aget heap-f32 offset)
|
|
y (aget heap-f32 (+ offset 1))
|
|
width (aget heap-f32 (+ offset 2))
|
|
height (aget heap-f32 (+ offset 3))]
|
|
(mem/free)
|
|
{:x x :y y :width width :height height})))))
|
|
|
|
(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-set-composition-cursor
|
|
"Place the WASM caret `offset` characters into the composition preview."
|
|
[offset]
|
|
(when (wasm/ready?)
|
|
(h/call wasm/internal-module "_text_editor_set_composition_cursor" offset)))
|
|
|
|
(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-select-paragraph
|
|
[{:keys [x y]}]
|
|
(when (wasm/ready?)
|
|
(h/call wasm/internal-module "_text_editor_select_paragraph" 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))
|
|
|
|
;; Typography chosen at a collapsed caret: not applied to existing text, but
|
|
;; picked up (as a new span) by the next inserted text. Keyed by shape-id.
|
|
(def ^:private pending-caret-styles (atom {}))
|
|
|
|
(defn merge-pending-caret-styles!
|
|
"Stack `styles` onto the shape's pending caret style."
|
|
[shape-id styles]
|
|
(swap! pending-caret-styles update shape-id merge styles))
|
|
|
|
(defn get-pending-caret-styles
|
|
[shape-id]
|
|
(get @pending-caret-styles shape-id))
|
|
|
|
(defn clear-pending-caret-styles!
|
|
"Drop every pending caret style; only the active shape can hold one."
|
|
[]
|
|
(reset! pending-caret-styles {}))
|
|
|
|
(defn merge-exported-texts-into-content
|
|
"Merge exported spans back into the existing content tree.
|
|
|
|
The WASM editor may split or merge paragraphs (Enter / Backspace at a
|
|
paragraph boundary, paste of several lines), so the exported structure can
|
|
differ from the original one, and a positional merge would leave the text of
|
|
one paragraph wearing the styling of another. Every exported span carries the
|
|
position it had in the tree we last exchanged with WASM (`:p`/`:s`), so the
|
|
styling is taken from there; a span WASM never saw falls back to its position
|
|
and then to the last existing span.
|
|
|
|
exported vector of paragraphs, each a vector of `{:p 0 :s 0 :t \"text\"}`
|
|
content existing Penpot content map (root -> paragraph-set -> …)"
|
|
[content exported]
|
|
(let [para-set (first (get content :children))
|
|
orig-paras (get para-set :children)
|
|
last-orig-para (when (seq orig-paras) (last orig-paras))
|
|
template-span (when last-orig-para
|
|
(-> last-orig-para :children last))
|
|
|
|
styling-para
|
|
(fn [para-idx spans]
|
|
(or (get orig-paras (get (first spans) :p))
|
|
(get orig-paras para-idx)
|
|
last-orig-para))
|
|
|
|
styling-span
|
|
(fn [orig-para span-idx {:keys [p s]}]
|
|
(or (get-in orig-paras [p :children s])
|
|
(get-in orig-para [:children span-idx])
|
|
(-> orig-para :children last)
|
|
template-span))
|
|
|
|
new-paras
|
|
(mapv (fn [para-idx spans]
|
|
(let [orig-para (styling-para para-idx spans)]
|
|
(assoc orig-para :children
|
|
(if (seq spans)
|
|
(mapv (fn [span-idx span]
|
|
(-> (styling-span orig-para span-idx span)
|
|
(assoc :text (get span :t))))
|
|
(range (count spans))
|
|
spans)
|
|
;; A paragraph with no spans is dropped on the way
|
|
;; back to WASM (and fails the content schema).
|
|
[(assoc (or template-span {}) :text "")]))))
|
|
(range (count exported))
|
|
exported)
|
|
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 (when shape-id (text-editor-export-content))]
|
|
(when (and shape-id new-texts)
|
|
(let [texts-clj (js->clj new-texts :keywordize-keys true)
|
|
;; 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 caret-position
|
|
"Collapsed caret as {:para :offset} from the WASM selection, or nil."
|
|
[]
|
|
(when-let [{:keys [focus-para focus-offset]} (text-editor-get-selection)]
|
|
{:para focus-para :offset focus-offset}))
|
|
|
|
(defn selection-start
|
|
"Start of the WASM selection as {:para :offset}: where inserted text goes."
|
|
[]
|
|
(when-let [selection (text-editor-get-selection)]
|
|
(let [{:keys [start-para start-offset]} (normalize-selection selection)]
|
|
{:para start-para :offset start-offset})))
|
|
|
|
(defn typed-range
|
|
"Normalized range covering the text inserted between `before` and `after`, or nil."
|
|
[before after]
|
|
(when (and before after)
|
|
(normalize-selection {:anchor-para (:para before) :anchor-offset (:offset before)
|
|
:focus-para (:para after) :focus-offset (:offset after)})))
|
|
|
|
(defn- para-char-count
|
|
[para]
|
|
(apply + (map (fn [span] (count (:text span))) (:children para))))
|
|
|
|
(defn- span-japanese-styles
|
|
"Japanese span attributes with their defaults filled in. Every selection
|
|
snapshot carries all of them, so moving onto an unstyled span resets the
|
|
controls."
|
|
[span]
|
|
(reduce-kv
|
|
(fn [styles attr default]
|
|
(assoc styles attr (or (get span attr) default)))
|
|
{}
|
|
cjk/span-attr-defaults))
|
|
|
|
(defn- merge-selection-styles
|
|
[result styles]
|
|
(reduce-kv
|
|
(fn [result attr value]
|
|
(update result attr #(if (or (nil? %) (= % value)) value :multiple)))
|
|
result
|
|
styles))
|
|
|
|
(defn- span-at-offset
|
|
"Span the WASM editor uses for a collapsed caret. At a span boundary the
|
|
preceding span wins, as in find_text_span_at_offset in render-wasm."
|
|
[paragraph offset]
|
|
(let [spans (:children paragraph)]
|
|
(loop [remaining-spans spans
|
|
accumulated 0]
|
|
(if-let [span (first remaining-spans)]
|
|
(let [span-end (+ accumulated (count (:text span)))]
|
|
(if (<= offset span-end)
|
|
span
|
|
(recur (rest remaining-spans) span-end)))
|
|
(last spans)))))
|
|
|
|
(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- selected-spans
|
|
"Spans of `paragraphs` that overlap a normalized selection range."
|
|
[paragraphs {:keys [start-para start-offset end-para end-offset]}]
|
|
(mapcat (fn [idx]
|
|
(when-let [para (nth paragraphs idx nil)]
|
|
(paragraph-selected-spans para
|
|
(if (= idx start-para) start-offset 0)
|
|
(if (= idx end-para) end-offset (para-char-count para)))))
|
|
(range start-para (inc end-para))))
|
|
|
|
(defn selection-japanese-styles
|
|
"Japanese span styles of a WASM selection, read from the cached content.
|
|
A range with differing values reports :multiple; a caret reports its
|
|
span's style."
|
|
[content selection]
|
|
(when (and content selection)
|
|
(let [{:keys [start-para start-offset end-para end-offset] :as sel-range}
|
|
(normalize-selection selection)
|
|
paragraphs (-> content :children first :children)
|
|
collapsed? (and (= start-para end-para)
|
|
(= start-offset end-offset))
|
|
spans
|
|
(if collapsed?
|
|
(some-> (get paragraphs start-para)
|
|
(span-at-offset start-offset)
|
|
vector)
|
|
(selected-spans paragraphs sel-range))]
|
|
(when (seq spans)
|
|
(reduce (fn [result span]
|
|
(merge-selection-styles result (span-japanese-styles span)))
|
|
{}
|
|
spans)))))
|
|
|
|
(defn text-editor-get-current-japanese-styles
|
|
"Japanese span styles of the active WASM editor selection."
|
|
[]
|
|
(when (wasm/ready?)
|
|
(let [shape-id (text-editor-get-active-shape-id)
|
|
selection (text-editor-get-selection)]
|
|
(selection-japanese-styles (get-cached-content shape-id) selection))))
|
|
|
|
(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)
|
|
;; An empty span has no range to overlap, but an empty
|
|
;; line inside the selection still has to be restyled.
|
|
has-overlap? (or (< ol-start ol-end)
|
|
(and (zero? span-len)
|
|
(<= sel-start pos sel-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 selection-fills
|
|
"The selection's fills: shared vector if all spans match, `:multiple` if not, nil if empty."
|
|
[content selection]
|
|
(let [selected (selected-spans (:children (first (:children content))) selection)
|
|
fills-set (into #{} (map :fills) selected)]
|
|
(cond
|
|
(empty? selected) nil
|
|
(= 1 (count fills-set)) (first fills-set)
|
|
:else :multiple)))
|
|
|
|
(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))
|
|
paragraphs (:children paragraph-set)
|
|
new-paragraphs (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)]
|
|
(assoc content :children [(assoc paragraph-set :children new-paragraphs)])))
|
|
|
|
(defn- slice-paragraph
|
|
"`para` with only the text in [start, end), cutting the spans at both ends.
|
|
With no text left it keeps its first span, empty, for the style."
|
|
[para start end]
|
|
(let [spans (loop [spans (:children para)
|
|
pos 0
|
|
acc []]
|
|
(if (empty? spans)
|
|
acc
|
|
(let [span (first spans)
|
|
text (:text span)
|
|
span-end (+ pos (count text))
|
|
ol-start (max pos start)
|
|
ol-end (min span-end end)]
|
|
(recur (rest spans) span-end
|
|
(cond-> acc
|
|
(< ol-start ol-end)
|
|
(conj (assoc span :text (subs text (- ol-start pos) (- ol-end pos)))))))))]
|
|
(assoc para :children (if (seq spans)
|
|
spans
|
|
[(assoc (first (:children para)) :text "")]))))
|
|
|
|
(defn content-range
|
|
"The part of `content` inside the char range, with its paragraph and span styles."
|
|
[content {:keys [start-para start-offset end-para end-offset]}]
|
|
(let [paragraph-set (first (:children content))
|
|
paragraphs (->> (:children paragraph-set)
|
|
(map-indexed vector)
|
|
(keep (fn [[idx para]]
|
|
(when (<= start-para idx end-para)
|
|
(slice-paragraph para
|
|
(if (= idx start-para) start-offset 0)
|
|
(if (= idx end-para) end-offset (para-char-count para))))))
|
|
(vec))]
|
|
(assoc content :children [(assoc paragraph-set :children paragraphs)])))
|
|
|
|
(defn selection-content
|
|
"The content of the editor selection in `shape-id`, or nil when it is collapsed."
|
|
[shape-id]
|
|
(when-let [selection (text-editor-get-selection)]
|
|
(let [{:keys [start-para start-offset end-para end-offset] :as range}
|
|
(normalize-selection selection)]
|
|
(when-let [content (get-cached-content shape-id)]
|
|
(when-not (and (= start-para end-para) (= start-offset end-offset))
|
|
(content-range content range))))))
|
|
|
|
(defn 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
|
|
`:text-decoration` is the panel's toggle-off, so it becomes \"none\"."
|
|
[styles]
|
|
(if (fn? styles)
|
|
styles
|
|
(into {}
|
|
(keep (fn [[k v]]
|
|
(cond
|
|
(some? v) [k v]
|
|
(= k :text-decoration) [k "none"])))
|
|
styles)))
|
|
|
|
(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 [styles (clean-styles 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))
|
|
|
|
new-content (when (not collapsed?)
|
|
(apply-styles-over-range content normalized-selection styles))]
|
|
|
|
(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)))))))))))
|
|
|
|
(defn apply-styles-to-range
|
|
"Like `apply-styles-to-selection` but over an explicit range (used to restyle
|
|
just-inserted text); returns `{:shape-id :content}` or nil."
|
|
[shape-id {:keys [start-para start-offset end-para end-offset] :as range} styles
|
|
use-shape-fn set-shape-text-content-fn]
|
|
(when (wasm/ready?)
|
|
(let [styles (clean-styles styles)
|
|
content (get-cached-content shape-id)]
|
|
(when (and content
|
|
(seq styles)
|
|
(not (and (= start-para end-para) (= start-offset end-offset))))
|
|
(let [new-content (apply-styles-over-range content range styles)]
|
|
(update-cached-content! shape-id new-content)
|
|
(use-shape-fn shape-id)
|
|
(set-shape-text-content-fn shape-id new-content)
|
|
{:shape-id shape-id
|
|
:content new-content})))))
|
|
|
|
(defn apply-paragraph-attrs-to-range
|
|
"Apply paragraph attrs to the paragraphs `selection` touches (a caret
|
|
touches one). Whole-shape attrs (writing mode, orientation) go to every
|
|
paragraph; a nil value removes them."
|
|
[content selection attrs]
|
|
(let [{:keys [start-para end-para]} (normalize-selection selection)
|
|
whole-attrs (select-keys attrs cjk/whole-shape-paragraph-attrs)
|
|
range-attrs (apply dissoc attrs cjk/whole-shape-paragraph-attrs)
|
|
apply-whole (fn [para]
|
|
(reduce-kv (fn [para attr value]
|
|
(if (some? value)
|
|
(assoc para attr value)
|
|
(dissoc para attr)))
|
|
para
|
|
whole-attrs))
|
|
paragraph-set (first (:children content))
|
|
new-paragraphs (into []
|
|
(map-indexed (fn [idx para]
|
|
(cond-> (apply-whole para)
|
|
(<= start-para idx end-para)
|
|
(merge range-attrs))))
|
|
(:children paragraph-set))]
|
|
(assoc content :children [(assoc paragraph-set :children new-paragraphs)])))
|
|
|
|
(defn apply-paragraph-attrs-to-selection
|
|
"Apply paragraph level attrs to the active editor selection (see
|
|
`apply-paragraph-attrs-to-range`)."
|
|
[attrs use-shape-fn set-shape-text-content-fn]
|
|
(when (wasm/ready?)
|
|
(let [shape-id (text-editor-get-active-shape-id)
|
|
selection (text-editor-get-selection)]
|
|
(when (and shape-id selection)
|
|
(when-let [content (get-cached-content shape-id)]
|
|
(let [new-content (apply-paragraph-attrs-to-range content selection attrs)]
|
|
(update-cached-content! shape-id new-content)
|
|
(use-shape-fn shape-id)
|
|
(set-shape-text-content-fn shape-id new-content)
|
|
{:shape-id shape-id
|
|
:content new-content}))))))
|