🎉 Add style data from text editor v3

This commit is contained in:
Aitor Moreno 2026-03-16 12:24:05 +01:00
parent 12382cfbb9
commit 101b2fe9e6
20 changed files with 1167 additions and 246 deletions

View File

@ -509,6 +509,7 @@
(dissoc
:current-file-id
:workspace-editor-state
:workspace-wasm-editor-styles
:workspace-media-objects
:workspace-persistence
:workspace-presence

View File

@ -8,6 +8,9 @@
(:require
[app.main.data.helpers :as dsh]
[app.main.data.workspace.path.common :as dwpc]
[app.main.features :as features]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.text-editor :as text-editor]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@ -50,12 +53,19 @@
(-> state
(update :workspace-local dissoc :edition :edit-path)
(update :workspace-drawing dissoc :tool :object :lock)
(dissoc :workspace-grid-edition)))
(dissoc :workspace-grid-edition)
(dissoc :workspace-wasm-editor-styles)))
ptk/WatchEvent
(watch [_ state _]
(let [id (get-in state [:workspace-local :edition])]
(rx/concat
(when (some? id)
(dwpc/finish-path)))))))
(dwpc/finish-path)))))
ptk/EffectEvent
(effect [_ state _]
(when (features/active-feature? state "text-editor-wasm/v1")
(text-editor/text-editor-dispose)
(wasm.api/request-render "clear-edition-mode")))))

View File

@ -114,11 +114,22 @@
(defn calculate-text-values
[shape]
(let [state-map (if (features/active-feature? @st/state "text-editor/v2")
(let [state-map (cond
(features/active-feature? @st/state "text-editor-wasm/v1")
(deref refs/workspace-wasm-editor-styles)
(features/active-feature? @st/state "text-editor/v2")
(deref refs/workspace-v2-editor-state)
:else
(deref refs/workspace-editor-state))
editor-styles (when (features/active-feature? @st/state "text-editor-wasm/v1")
(get state-map (:id shape)))
editor-state (when-not (features/active-feature? @st/state "text-editor/v2")
(get state-map (:id shape)))
editor-instance (when (features/active-feature? @st/state "text-editor/v2")
(deref refs/workspace-editor))]
(d/merge
@ -126,12 +137,14 @@
{:shape shape
:attrs txt/root-attrs})
(dwt/current-paragraph-values
{:editor-state editor-state
{:editor-styles editor-styles
:editor-state editor-state
:editor-instance editor-instance
:shape shape
:attrs txt/paragraph-attrs})
(dwt/current-text-values
{:editor-state editor-state
{:editor-styles editor-styles
:editor-state editor-state
:editor-instance editor-instance
:shape shape
:attrs txt/text-node-attrs}))))

View File

@ -250,6 +250,14 @@
[{:keys [attrs shape]}]
(shape-current-values shape txt/is-root-node? attrs))
(defn v3-current-text-values
[{:keys [editor-styles attrs]}]
(let [result (-> editor-styles
;; If we use dm/select-keys compilation fails
(select-keys attrs))
result (if (empty? result) txt/default-text-attrs result)]
result))
(defn v2-current-text-values
[{:keys [editor-instance attrs]}]
(let [result (-> (.-currentStyle editor-instance)
@ -266,8 +274,9 @@
(shape-current-values shape txt/is-paragraph-node? attrs)))
(defn current-paragraph-values
[{:keys [editor-state editor-instance attrs shape] :as options}]
[{:keys [editor-styles editor-state editor-instance attrs shape] :as options}]
(cond
(some? editor-styles) (v3-current-text-values options)
(some? editor-instance) (v2-current-text-values options)
(some? editor-state) (v1-current-paragraph-values options)
:else (shape-current-values shape txt/is-paragraph-node? attrs)))
@ -282,8 +291,9 @@
result))
(defn current-text-values
[{:keys [editor-state editor-instance attrs shape] :as options}]
[{:keys [editor-styles editor-state editor-instance attrs shape] :as options}]
(cond
(some? editor-styles) (v3-current-text-values options)
(some? editor-instance) (v2-current-text-values options)
(some? editor-state) (v1-current-text-values options)
:else (shape-current-values shape txt/is-text-node? attrs)))
@ -480,13 +490,21 @@
(ptk/reify ::update-text-with-function
ptk/UpdateEvent
(update [_ state]
;; This is only called when `[:workspace-editor-state id]` is set, this property
;; keeps a Draft.js EditorState object.
(d/update-in-when state [:workspace-editor-state id] ted/update-editor-current-inline-styles-fn (comp update-node-fn migrate-node)))
ptk/WatchEvent
(watch [_ state _]
(when (or
(and (features/active-feature? state "text-editor/v2") (nil? (:workspace-editor state)))
(and (not (features/active-feature? state "text-editor/v2")) (nil? (get-in state [:workspace-editor-state id]))))
(and (features/active-feature? state "text-editor-wasm/v1")
(nil? (get-in state [:workspace-wasm-editor-styles id])))
(and (features/active-feature? state "text-editor/v2")
(not (features/active-feature? state "text-editor-wasm/v1"))
(nil? (:workspace-editor state)))
(and (not (features/active-feature? state "text-editor/v2"))
(not (features/active-feature? state "text-editor-wasm/v1"))
(nil? (get-in state [:workspace-editor-state id]))))
(let [page-id (or (get options :page-id)
(get state :current-page-id))
objects (dsh/lookup-page-objects state page-id)
@ -513,7 +531,12 @@
ptk/EffectEvent
(effect [_ state _]
(when (features/active-feature? state "text-editor/v2")
(cond
(features/active-feature? state "text-editor-wasm/v1")
(let [styles ((comp update-node-fn migrate-node))]
(wasm.api/apply-styles-to-selection styles))
(features/active-feature? state "text-editor/v2")
(when-let [instance (:workspace-editor state)]
(let [styles (some-> (editor.v2/getCurrentStyle instance)
(styles/get-styles-from-style-declaration :removed-mixed true)
@ -792,7 +815,7 @@
(when has-selection?
(let [span-attrs (select-keys attrs txt/text-node-attrs)]
(when (not (empty? span-attrs))
(let [result (wasm.api/apply-style-to-selection span-attrs)]
(let [result (wasm.api/apply-styles-to-selection span-attrs)]
(when result
(rx/of (v2-update-text-shape-content
(:shape-id result) (:content result)
@ -905,7 +928,7 @@
{:typography-ref-id typ-id
:typography-ref-file file-id}))))))))
;; -- New Editor
;; -- Text Editor v2
(defn v2-update-text-editor-styles
[id new-styles]
@ -1121,3 +1144,7 @@
(cond-> (or (some? width) (some? height))
(gsh/transform-shape (ctm/change-size shape width height))))))
{:undo-group (when new-shape? id)})))))))
;; -- Text Editor v3
;; @see texts_v3.cljs

View File

@ -0,0 +1,20 @@
;; 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
(ns app.main.data.workspace.texts-v3
(:require
[app.common.types.text :as txt]
[potok.v2.core :as ptk]))
(defn v3-update-text-editor-styles
[id new-styles]
(ptk/reify ::v3-update-text-editor-styles
ptk/UpdateEvent
(update [_ state]
(let [merged-styles (merge (txt/get-default-text-attrs)
(get-in state [:workspace-global :default-font])
new-styles)]
(update-in state [:workspace-wasm-editor-styles id] (fnil merge {}) merged-styles)))))

View File

@ -378,6 +378,9 @@
(def workspace-modifiers
(l/derived :workspace-modifiers st/state))
(def workspace-wasm-editor-styles
(l/derived :workspace-wasm-editor-styles st/state))
(def workspace-wasm-modifiers
(l/derived :workspace-wasm-modifiers st/state))

View File

@ -317,10 +317,6 @@
(reset! timeout-id (js/setTimeout schedule-blink caret-blink-interval-ms)))]
(schedule-blink)
(fn []
;; ESTO ES JUSTO LO QUE NO QUIERO, NO QUIERO QUE SE HAGA
;; DISPOSE CUANDO SE DESMONTA EL COMPONENTE.
#_(when (text-editor/text-editor-dispose)
(wasm.api/request-render "text-editor-dispose"))
(when @timeout-id
(js/clearTimeout @timeout-id))))))

View File

@ -305,6 +305,7 @@
(when (not= "INPUT" (-> (dom/get-active) (dom/get-tag-name)))
(let [node (txu/get-text-editor-content)]
(dom/focus! node))))))}]
(hooks/use-stream
expand-stream
#(swap! state* assoc-in [:more-options] true))

View File

@ -6,6 +6,7 @@
(ns app.main.ui.workspace.sidebar.options.shapes.text
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.shape.layout :as ctl]
[app.common.types.text :as txt]
@ -85,10 +86,20 @@
(mf/deref parents-by-ids-ref)
state-map
(if (features/active-feature? @st/state "text-editor/v2")
(cond
(features/active-feature? @st/state "text-editor-wasm/v1")
(mf/deref refs/workspace-wasm-editor-styles)
(features/active-feature? @st/state "text-editor/v2")
(mf/deref refs/workspace-v2-editor-state)
:else
(mf/deref refs/workspace-editor-state))
editor-styles
(when (features/active-feature? @st/state "text-editor-wasm/v1")
(get state-map id))
editor-state
(when (not (features/active-feature? @st/state "text-editor/v2"))
(get state-map id))
@ -99,25 +110,28 @@
fill-values
(dwt/current-text-values
{:editor-state editor-state
{:editor-styles editor-styles
:editor-state editor-state
:editor-instance editor-instance
:shape shape
:attrs (conj txt/text-fill-attrs :fills)})
text-values
(merge
(d/merge
(select-keys shape [:grow-type])
(select-keys shape fill/fill-attrs)
(dwt/current-root-values
{:shape shape
:attrs txt/root-attrs})
(dwt/current-paragraph-values
{:editor-state editor-state
{:editor-styles editor-styles
:editor-state editor-state
:editor-instance editor-instance
:shape shape
:attrs txt/paragraph-attrs})
(dwt/current-text-values
{:editor-state editor-state
{:editor-styles editor-styles
:editor-state editor-state
:editor-instance editor-instance
:shape shape
:attrs txt/text-node-attrs}))]

View File

@ -22,6 +22,7 @@
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.workspace.texts-v3 :as texts]
[app.main.refs :as refs]
[app.main.router :as rt]
[app.main.store :as st]
@ -51,6 +52,7 @@
[cuerdas.core :as str]
[promesa.core :as p]
[rumext.v2 :as mf]))
(def use-dpr? (contains? cf/flags :render-wasm-dpr))
(defn text-editor-wasm?
@ -71,7 +73,6 @@
(def ^:const INPUT-MODIFIER-U8-SIZE 44)
(def ^:const INPUT-MODIFIER-U32-SIZE (/ INPUT-MODIFIER-U8-SIZE 4))
(def ^:const GRID-LAYOUT-ROW-U8-SIZE 8)
(def ^:const GRID-LAYOUT-COLUMN-U8-SIZE 8)
(def ^:const GRID-LAYOUT-CELL-U8-SIZE 36)
@ -86,6 +87,13 @@
;; Threshold below which we use synchronous processing (no chunking overhead)
(def ^:const ASYNC_THRESHOLD 100)
;; Text editor events.
(def ^:const TEXT_EDITOR_EVENT_NONE 0)
(def ^:const TEXT_EDITOR_EVENT_CONTENT_CHANGED 1)
(def ^:const TEXT_EDITOR_EVENT_SELECTION_CHANGED 2)
(def ^:const TEXT_EDITOR_EVENT_STYLES_CHANGED 3)
(def ^:const TEXT_EDITOR_EVENT_NEEDS_LAYOUT 4)
;; Re-export public WebGL functions
(def capture-canvas-pixels webgl/capture-canvas-pixels)
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
@ -99,6 +107,7 @@
(def text-editor-pointer-down text-editor/text-editor-pointer-down)
(def text-editor-pointer-move text-editor/text-editor-pointer-move)
(def text-editor-pointer-up text-editor/text-editor-pointer-up)
(def text-editor-get-current-styles text-editor/text-editor-get-current-styles)
(def text-editor-has-focus? text-editor/text-editor-has-focus?)
(def text-editor-has-selection? text-editor/text-editor-has-selection?)
(def text-editor-select-all text-editor/text-editor-select-all)
@ -162,18 +171,24 @@
;; Update text editor blink (so cursor toggles) using the same timestamp
(try
(when wasm/context-initialized?
;; Render text editor overlay on top of main canvas (only if feature enabled)
;; Determine if text-editor-wasm feature is active without requiring
;; app.main.features to avoid circular dependency: check runtime and
;; persisted feature sets in the store state.
(when (is-text-editor-wasm-enabled @st/state)
(text-editor/text-editor-update-blink timestamp)
(text-editor/text-editor-render-overlay)
;; Poll for editor events; if any event occurs, trigger a re-render
(let [ev (text-editor/text-editor-poll-event)]
(when (and ev (not= ev 0))
(request-render "text-editor-event")))))
(when (is-text-editor-wasm-enabled @st/state)
(text-editor/text-editor-update-blink timestamp)
(text-editor/text-editor-render-overlay)
;; Poll for editor events; if any event occurs, trigger a re-render
(let [ev (text-editor/text-editor-poll-event)]
(when (and ev (not= ev TEXT_EDITOR_EVENT_NONE))
;; When StylesChanged, get the current styles.
(case ev
;; StylesChanged Event
TEXT_EDITOR_EVENT_STYLES_CHANGED
(let [current-styles (text-editor/text-editor-get-current-styles)
shape-id (text-editor/text-editor-get-active-shape-id)]
(st/emit! (texts/v3-update-text-editor-styles shape-id current-styles)))
;; Default case
nil)
(request-render "text-editor-event"))))
(catch :default e
(js/console.error "text-editor overlay/update failed:" e)))
@ -279,11 +294,12 @@
(h/call wasm/internal-module "_update_shape_text_layout")
result))
(defn apply-style-to-selection
(defn apply-styles-to-selection
"Apply style attrs to the currently selected text spans.
Updates the cached content, pushes to WASM, and returns {:shape-id :content} for saving."
[attrs]
(text-editor/apply-style-to-selection attrs use-shape set-shape-text-content))
(text-editor/apply-styles-to-selection attrs use-shape set-shape-text-content)
(request-render "apply-styles-to-selection"))
(defn set-parent-id
[id]

View File

@ -75,6 +75,20 @@
:builtin
uuid/zero))
(defn uuid->font-id
[font-uuid]
(if (= font-uuid uuid/zero)
"sourcesanspro"
(or (:id (fonts/find-font-data {:uuid font-uuid}))
(dm/str "custom-" font-uuid))))
(defn uuid->font-variant-id
[font-id font-variant-uuid]
(if (= font-variant-uuid uuid/zero)
"regular"
(or (:id (d/seek #(= (:uuid %) font-variant-uuid)
(:variants (fonts/get-font-data font-id))))
"regular")))
(defn ^:private font-id->asset-id [font-id font-variant-id font-weight font-style]
(case (font-backend font-id)

View File

@ -64,25 +64,12 @@
(defn read-string
"Read a UTF-8 string from WASM memory given a byte pointer/offset.
Uses Emscripten's UTF8ToString to decode the string."
[ptr]
(h/call wasm/internal-module "UTF8ToString" ptr))
(defn read-null-terminated-string
"Read a null-terminated UTF-8 string from WASM memory.
Manually reads bytes until null terminator and decodes using TextDecoder."
[ptr]
(when (and ptr (not (zero? ptr)))
(let [heap (get-heap-u8)
;; Find the null terminator
end-idx (loop [idx ptr]
(if (zero? (aget heap idx))
idx
(recur (inc idx))))
;; Extract the bytes (excluding null terminator)
bytes (.slice heap ptr end-idx)
;; Decode using TextDecoder
decoder (js/TextDecoder. "utf-8")]
(.decode decoder bytes))))
([ptr max-bytes ignore-null]
(h/call wasm/internal-module "UTF8ToString" ptr max-bytes ignore-null))
([ptr max-bytes]
(h/call wasm/internal-module "UTF8ToString" ptr max-bytes))
([ptr]
(h/call wasm/internal-module "UTF8ToString" ptr)))
(defn slice
"Returns a copy of a portion of a typed array into a new typed array

View File

@ -7,11 +7,119 @@
(ns app.render-wasm.text-editor
"Text editor WASM bindings"
(:require
[app.common.types.fills.impl :as types.fills.impl]
[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.wasm :as wasm]))
(def ^:const TEXT_EDITOR_STYLES_METADATA_SIZE (* 30 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)))
(defn text-editor-focus
[id]
(when wasm/context-initialized?
@ -66,6 +174,143 @@
(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-translate-vertical-align
[vertical-align]
(case vertical-align
0 "top"
1 "center"
2 "bottom"))
(defn- text-editor-translate-text-align
[text-align]
(case text-align
0 "left"
1 "center"
2 "right"
text-align))
(defn- text-editor-translate-text-direction
[text-direction]
(case text-direction
0 "ltr"
1 "rtl"
text-direction))
(defn- text-editor-translate-text-transform
[text-transform]
(case text-transform
0 "none"
1 "lowercase"
2 "uppercase"
3 "capitalize"
text-transform))
(defn- text-editor-translate-text-decoration
[text-decoration]
(case text-decoration
0 "none"
1 "underline"
2 "linethrough"
3 "overline"
text-decoration))
(defn- text-editor-translate-font-style
[font-style]
(case font-style
0 "normal"
1 "italic"
font-style))
(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/context-initialized?
(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-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))
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))
num-fills (aget heap-u32 (+ u32-offset 11))
text-align-value (aget heap-u32 (+ u32-offset 12))
text-direction-value (aget heap-u32 (+ u32-offset 13))
text-decoration-value (aget heap-u32 (+ u32-offset 14))
text-transform-value (aget heap-u32 (+ u32-offset 15))
font-family-id-a (aget heap-u32 (+ u32-offset 16))
font-family-id-b (aget heap-u32 (+ u32-offset 17))
font-family-id-c (aget heap-u32 (+ u32-offset 18))
font-family-id-d (aget heap-u32 (+ u32-offset 19))
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-family-style-value (aget heap-u32 (+ u32-offset 20))
_font-family-weight-value (aget heap-u32 (+ u32-offset 21))
font-size-value (aget heap-f32 (+ u32-offset 22))
font-weight-value (aget heap-i32 (+ u32-offset 23))
line-height-value (aget heap-f32 (+ u32-offset 28))
letter-spacing-value (aget heap-f32 (+ u32-offset 29))
font-id (fonts/uuid->font-id font-family-id-value)
font-style-value (text-editor-translate-font-style (text-editor-get-style-property font-family-state font-family-style-value))
font-variant-id-computed (text-editor-compute-font-variant-id font-id font-weight-value font-style-value)
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 []))
result {:vertical-align (text-editor-translate-vertical-align vertical-align)
:text-align (text-editor-translate-text-align (text-editor-get-style-property text-align-state text-align-value))
:text-direction (text-editor-translate-text-direction (text-editor-get-style-property text-direction-state text-direction-value))
:text-decoration (text-editor-translate-text-decoration (text-editor-get-style-property text-decoration-state text-decoration-value))
:text-transform (text-editor-translate-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-state font-id)
:font-id (text-editor-get-style-property font-family-state font-id)
:font-variant-id (text-editor-get-style-property font-variant-id-state font-variant-id-computed)
:typography-ref-file nil
:typography-ref-id nil
:fills fills}]
(mem/free)
result)))))
(defn text-editor-encode-text-pre
[text]
(when (and (not (empty? text))
@ -171,7 +416,7 @@
(when wasm/context-initialized?
(let [ptr (h/call wasm/internal-module "_text_editor_export_content")]
(when (and ptr (not (zero? ptr)))
(let [json-str (mem/read-null-terminated-string ptr)]
(let [json-str (mem/read-string ptr)]
(mem/free)
(js/JSON.parse json-str))))))
@ -181,7 +426,7 @@
(when wasm/context-initialized?
(let [ptr (h/call wasm/internal-module "_text_editor_export_selection")]
(when (and ptr (not (zero? ptr)))
(let [text (mem/read-null-terminated-string ptr)]
(let [text (mem/read-string ptr)]
(mem/free)
text)))))
@ -342,38 +587,52 @@
[para]
(apply + (map (fn [span] (count (:text span))) (:children para))))
(defn apply-style-to-selection
(defn apply-styles-to-selection
[attrs use-shape-fn set-shape-text-content-fn]
(when wasm/context-initialized?
(let [shape-id (text-editor-get-active-shape-id)
sel (text-editor-get-selection)]
(when (and shape-id sel)
(let [shape-id (text-editor-get-active-shape-id)
selection (text-editor-get-selection)]
(when (and shape-id selection)
(let [content (get @shape-text-contents shape-id)]
(when content
(let [{:keys [start-para start-offset end-para end-offset]}
(normalize-selection sel)
collapsed? (and (= start-para end-para) (= start-offset end-offset))
para-set (first (:children content))
paras (:children para-set)
new-paras
(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 attrs)
;; first paragraph
(= idx start-para)
(apply-attrs-to-paragraph para start-offset (para-char-count para) attrs)
;; final paragraph
(= idx end-para)
(apply-attrs-to-paragraph para 0 end-offset attrs)
;; any other paragraph
:else
(apply-attrs-to-paragraph para 0 (para-char-count para) attrs)))
(range (count paras))
paras))
new-content (when new-paras
(range (count paragraphs))
paragraphs))
new-content (when new-paragraphs
(assoc content :children
[(assoc para-set :children new-paras)]))]
[(assoc paragraph-set :children new-paragraphs)]))]
(when new-content
(swap! shape-text-contents assoc shape-id new-content)
(use-shape-fn shape-id)

View File

@ -39,7 +39,7 @@ export EMCC_CFLAGS="--no-entry \
-sERROR_ON_UNDEFINED_SYMBOLS=0 \
-sMAX_WEBGL_VERSION=2 \
-sEXPORT_NAME=createRustSkiaModule \
-sEXPORTED_RUNTIME_METHODS=GL,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \
-sEXPORTED_RUNTIME_METHODS=GL,UTF8ToString,stringToUTF8,HEAPU8,HEAP32,HEAPU32,HEAPF32 \
-sENVIRONMENT=web \
-sMODULARIZE=1 \
-sEXPORT_ES6=1";

View File

@ -153,10 +153,11 @@ pub enum ConstraintH {
}
#[derive(Debug, Clone, PartialEq, Copy)]
#[repr(u8)]
pub enum VerticalAlign {
Top,
Center,
Bottom,
Top = 0,
Center = 1,
Bottom = 2,
}
#[derive(Debug, Clone, PartialEq, Copy)]

View File

@ -3,9 +3,10 @@ use std::fmt;
use crate::uuid::Uuid;
#[derive(Debug, PartialEq, Clone, Copy)]
#[repr(u8)]
pub enum FontStyle {
Normal,
Italic,
Normal = 0,
Italic = 1,
}
impl fmt::Display for FontStyle {
@ -33,6 +34,16 @@ impl FontFamily {
pub fn alias(&self) -> String {
format!("{}", self)
}
pub fn id(&self) -> Uuid {
self.id
}
pub fn style(&self) -> FontStyle {
self.style
}
pub fn weight(&self) -> u32 {
self.weight
}
}
impl fmt::Display for FontFamily {

View File

@ -1,8 +1,14 @@
#![allow(dead_code)]
use crate::shapes::{TextContent, TextPositionWithAffinity};
use macros::ToJs;
use crate::shapes::{
Fill, FontFamily, TextAlign, TextContent, TextDecoration, TextDirection,
TextPositionWithAffinity, TextTransform, VerticalAlign,
};
use crate::uuid::Uuid;
use crate::wasm::text::helpers as text_helpers;
use crate::wasm::text::helpers::{self as text_helpers, find_text_span_at_offset};
use crate::wasm::text_editor::CursorDirection;
use skia_safe::{
textlayout::{Affinity, PositionWithAffinity},
Color,
@ -82,13 +88,14 @@ impl TextSelection {
}
/// Events that the text editor can emit for frontend synchronization
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, ToJs)]
pub enum TextEditorEvent {
None = 0,
ContentChanged = 1,
SelectionChanged = 2,
NeedsLayout = 3,
StylesChanged = 3,
NeedsLayout = 4,
}
/// FIXME: It should be better to get these constants from the frontend through the API.
@ -97,6 +104,154 @@ const CURSOR_WIDTH: f32 = 1.5;
const CURSOR_COLOR: Color = Color::BLACK;
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
#[derive(Debug)]
pub struct TextEditorStyles {
pub vertical_align: VerticalAlign,
pub text_align: Multiple<TextAlign>, // Multiple
pub text_direction: Multiple<TextDirection>, // Multiple
pub text_decoration: Multiple<TextDecoration>,
pub text_transform: Multiple<TextTransform>,
pub font_family: Multiple<FontFamily>,
pub font_size: Multiple<f32>,
pub font_weight: Multiple<i32>,
pub font_variant_id: Multiple<Uuid>,
pub line_height: Multiple<f32>,
pub letter_spacing: Multiple<f32>,
pub fills: Vec<Fill>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(u8)]
pub enum MultipleState {
Undefined = 0,
Single = 1,
Multiple = 2,
}
#[derive(Debug)]
pub struct Multiple<T> {
state: MultipleState,
value: Option<T>,
}
impl<T> Multiple<T> {
pub fn empty() -> Self {
Self {
state: MultipleState::Undefined,
value: None,
}
}
pub fn new(state: MultipleState, value: Option<T>) -> Self {
Self { state, value }
}
pub fn state(&self) -> &MultipleState {
&self.state
}
pub fn value(&self) -> &Option<T> {
&self.value
}
pub fn is_undefined(&self) -> bool {
self.state == MultipleState::Undefined
}
pub fn is_multiple(&self) -> bool {
self.state == MultipleState::Multiple
}
pub fn is_single(&self) -> bool {
self.state == MultipleState::Single
}
pub fn is_single_some(&self) -> bool {
!self.is_undefined() && self.value.is_some()
}
pub fn is_single_none(&self) -> bool {
!self.is_undefined() && self.value.is_none()
}
pub fn reset(&mut self) {
self.state = MultipleState::Undefined;
self.value = None;
}
pub fn set(&mut self, value: Option<T>) {
if self.state == MultipleState::Undefined || self.state == MultipleState::Multiple {
self.state = MultipleState::Single;
}
self.value = value;
}
pub fn set_single(&mut self, value: Option<T>) {
self.state = MultipleState::Single;
self.value = value;
}
pub fn set_multiple(&mut self) {
self.state = MultipleState::Multiple;
self.value = None;
}
pub fn merge(&mut self, value: Option<T>) -> bool
where
T: PartialEq,
{
if self.state == MultipleState::Multiple {
return false;
}
if self.state == MultipleState::Undefined {
self.set_single(value);
return true;
}
if self.value.as_ref() != value.as_ref() {
self.set_multiple();
return false;
}
self.value = value;
true
}
}
impl TextEditorStyles {
pub fn new() -> Self {
Self {
vertical_align: VerticalAlign::Top,
text_align: Multiple::empty(),
text_direction: Multiple::empty(),
text_decoration: Multiple::empty(),
text_transform: Multiple::empty(),
font_family: Multiple::empty(),
font_size: Multiple::empty(),
font_weight: Multiple::empty(),
font_variant_id: Multiple::empty(),
line_height: Multiple::empty(),
letter_spacing: Multiple::empty(),
fills: Vec::new(),
}
}
pub fn reset(&mut self) {
self.text_align.reset();
self.text_direction.reset();
self.text_decoration.reset();
self.text_transform.reset();
self.font_family.reset();
self.font_size.reset();
self.font_weight.reset();
self.font_variant_id.reset();
self.line_height.reset();
self.letter_spacing.reset();
self.fills.clear();
}
}
pub struct TextEditorTheme {
pub selection_color: Color,
pub cursor_width: f32,
@ -172,6 +327,7 @@ pub struct TextEditorState {
pub active_shape_id: Option<Uuid>,
pub cursor_visible: bool,
pub last_blink_time: f64,
pub current_styles: TextEditorStyles,
pending_events: Vec<TextEditorEvent>,
}
@ -191,6 +347,7 @@ impl TextEditorState {
cursor_visible: true,
last_blink_time: 0.0,
pending_events: Vec::new(),
current_styles: TextEditorStyles::new(),
}
}
@ -240,11 +397,11 @@ impl TextEditorState {
true
}
pub fn select_all(&mut self, content: &TextContent) -> bool {
pub fn select_all(&mut self, text_content: &TextContent) -> bool {
self.is_pointer_selection_active = false;
self.set_caret_from_position(&TextPositionWithAffinity::empty());
let num_paragraphs = content.paragraphs().len() - 1;
let Some(last_paragraph) = content.paragraphs().last() else {
let num_paragraphs = text_content.paragraphs().len() - 1;
let Some(last_paragraph) = text_content.paragraphs().last() else {
return false;
};
#[allow(dead_code)]
@ -264,20 +421,21 @@ impl TextEditorState {
num_paragraphs,
offset,
));
self.update_styles(text_content);
self.reset_blink();
self.push_event(crate::state::TextEditorEvent::SelectionChanged);
self.push_event(TextEditorEvent::SelectionChanged);
true
}
pub fn select_word_boundary(
&mut self,
content: &TextContent,
text_content: &TextContent,
position: &TextPositionWithAffinity,
) {
self.is_pointer_selection_active = false;
let paragraphs = content.paragraphs();
let paragraphs = text_content.paragraphs();
if paragraphs.is_empty() || position.paragraph >= paragraphs.len() {
return;
}
@ -295,6 +453,7 @@ impl TextEditorState {
position.paragraph,
0,
));
self.update_styles(text_content);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
return;
@ -316,6 +475,7 @@ impl TextEditorState {
position.paragraph,
position.offset.min(chars.len()),
));
self.update_styles(text_content);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
return;
@ -339,6 +499,7 @@ impl TextEditorState {
position.paragraph,
end,
));
self.update_styles(text_content);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
}
@ -353,6 +514,183 @@ impl TextEditorState {
self.push_event(TextEditorEvent::SelectionChanged);
}
fn update_styles_from_selection(&mut self, text_content: &TextContent) -> bool {
let paragraphs = text_content.paragraphs();
if paragraphs.is_empty() {
return false;
}
let start = self.selection.start();
if start.paragraph >= paragraphs.len() {
return false;
}
let end = self.selection.end();
let end_paragraph = end.paragraph.min(paragraphs.len() - 1);
self.current_styles.reset();
let mut has_selected_content = false;
let mut has_fills = false;
let mut fills_are_multiple = false;
for (para_idx, paragraph) in paragraphs
.iter()
.enumerate()
.take(end_paragraph + 1)
.skip(start.paragraph)
{
let paragraph_char_count: usize = paragraph
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.offset.min(paragraph_char_count)
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.offset.min(paragraph_char_count)
} else {
paragraph_char_count
};
if range_start >= range_end {
continue;
}
has_selected_content = true;
self.current_styles
.text_align
.merge(Some(paragraph.text_align()));
let mut char_pos = 0;
for span in paragraph.children() {
let span_len = span.text.chars().count();
let span_start = char_pos;
let span_end = char_pos + span_len;
char_pos += span_len;
let selected_start = range_start.max(span_start);
let selected_end = range_end.min(span_end);
if selected_start >= selected_end {
continue;
}
self.current_styles
.text_direction
.merge(Some(span.text_direction));
self.current_styles
.text_decoration
.merge(span.text_decoration);
self.current_styles
.text_transform
.merge(span.text_transform);
self.current_styles
.font_family
.merge(Some(span.font_family));
self.current_styles.font_size.merge(Some(span.font_size));
self.current_styles
.font_weight
.merge(Some(span.font_weight));
self.current_styles
.font_variant_id
.merge(Some(span.font_variant_id));
self.current_styles
.line_height
.merge(Some(span.line_height));
self.current_styles
.letter_spacing
.merge(Some(span.letter_spacing));
if !fills_are_multiple {
if !has_fills {
self.current_styles.fills = span.fills.clone();
has_fills = true;
} else if self.current_styles.fills != span.fills {
fills_are_multiple = true;
self.current_styles.fills.clear();
}
}
}
}
has_selected_content
}
fn update_styles_from_caret(&mut self, text_content: &TextContent) -> bool {
let focus = self.selection.focus;
let paragraphs = text_content.paragraphs();
let Some(current_paragraph) = paragraphs.get(focus.paragraph) else {
return false;
};
let current_offset = focus.offset;
let current_text_span = find_text_span_at_offset(current_paragraph, current_offset);
self.current_styles
.text_align
.set_single(Some(current_paragraph.text_align()));
if let Some((text_span_index, _)) = current_text_span {
if let Some(text_span) = current_paragraph.children().get(text_span_index) {
self.current_styles
.text_direction
.set_single(Some(text_span.text_direction));
self.current_styles
.text_decoration
.set_single(text_span.text_decoration);
self.current_styles
.text_transform
.set_single(text_span.text_transform);
self.current_styles
.font_family
.set_single(Some(text_span.font_family));
self.current_styles
.font_size
.set_single(Some(text_span.font_size));
self.current_styles
.font_weight
.set_single(Some(text_span.font_weight));
self.current_styles
.font_variant_id
.set_single(Some(text_span.font_variant_id));
self.current_styles
.line_height
.set_single(Some(text_span.line_height));
self.current_styles
.letter_spacing
.set_single(Some(text_span.letter_spacing));
self.current_styles.fills = text_span.fills.clone();
}
} else {
self.current_styles
.line_height
.set_single(Some(current_paragraph.line_height()));
self.current_styles
.letter_spacing
.set_single(Some(current_paragraph.letter_spacing()));
}
true
}
pub fn update_styles(&mut self, text_content: &TextContent) -> bool {
if self.selection.is_selection() {
let styles_were_updated = self.update_styles_from_selection(text_content);
if styles_were_updated {
self.push_event(TextEditorEvent::StylesChanged);
}
return styles_were_updated;
}
// It is a caret.
let styles_were_updated = self.update_styles_from_caret(text_content);
if styles_were_updated {
self.push_event(TextEditorEvent::StylesChanged);
}
styles_were_updated
}
pub fn update_blink(&mut self, timestamp_ms: f64) {
if !self.has_focus {
return;
@ -389,6 +727,145 @@ impl TextEditorState {
pub fn has_pending_events(&self) -> bool {
!self.pending_events.is_empty()
}
pub fn delete_backward(&mut self, text_content: &mut TextContent, word_boundary: bool) {
if self.selection.is_selection() {
text_helpers::delete_selection_range(text_content, &self.selection);
let start = self.selection.start();
let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs());
self.selection.set_caret(clamped);
} else if word_boundary {
let cursor = self.selection.focus;
if let Some(new_cursor) = text_helpers::delete_word_before(text_content, &cursor) {
self.selection.set_caret(new_cursor);
}
} else {
let cursor = self.selection.focus;
if let Some(new_cursor) = text_helpers::delete_char_before(text_content, &cursor) {
self.selection.set_caret(new_cursor);
}
}
text_content.layout.paragraphs.clear();
text_content.layout.paragraph_builders.clear();
self.reset_blink();
self.push_event(TextEditorEvent::ContentChanged);
self.push_event(TextEditorEvent::NeedsLayout);
}
pub fn delete_forward(&mut self, text_content: &mut TextContent, word_boundary: bool) {
if self.selection.is_selection() {
text_helpers::delete_selection_range(text_content, &self.selection);
let start = self.selection.start();
let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs());
self.selection.set_caret(clamped);
} else if word_boundary {
let cursor = self.selection.focus;
text_helpers::delete_word_after(text_content, &cursor);
let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs());
self.selection.set_caret(clamped);
} else {
let cursor = self.selection.focus;
text_helpers::delete_char_after(text_content, &cursor);
let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs());
self.selection.set_caret(clamped);
}
text_content.layout.paragraphs.clear();
text_content.layout.paragraph_builders.clear();
self.reset_blink();
self.push_event(TextEditorEvent::ContentChanged);
self.push_event(TextEditorEvent::NeedsLayout);
}
pub fn insert_paragraph(&mut self, text_content: &mut TextContent) {
if self.selection.is_selection() {
text_helpers::delete_selection_range(text_content, &self.selection);
let start = self.selection.start();
self.selection.set_caret(start);
}
let cursor = self.selection.focus;
if text_helpers::split_paragraph_at_cursor(text_content, &cursor) {
let new_cursor =
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0);
self.selection.set_caret(new_cursor);
}
text_content.layout.paragraphs.clear();
text_content.layout.paragraph_builders.clear();
self.reset_blink();
self.push_event(TextEditorEvent::ContentChanged);
self.push_event(TextEditorEvent::NeedsLayout);
}
pub fn move_cursor(
&mut self,
text_content: &TextContent,
direction: CursorDirection,
word_boundary: bool,
extend_selection: bool,
) -> bool {
let paragraphs = text_content.paragraphs();
if paragraphs.is_empty() {
return false;
}
let focus = self.selection.focus;
// Get the text direction of the span at the current cursor position
let text_span_text_direction = if focus.paragraph < paragraphs.len() {
text_helpers::get_text_span_text_direction_at_offset(
&paragraphs[focus.paragraph],
focus.offset,
)
} else {
TextDirection::LTR
};
// For horizontal navigation, swap Backward/Forward when in RTL text
let adjusted_direction = if text_span_text_direction == TextDirection::RTL {
match direction {
CursorDirection::Backward => CursorDirection::Forward,
CursorDirection::Forward => CursorDirection::Backward,
other => other,
}
} else {
direction
};
let new_cursor = match adjusted_direction {
CursorDirection::Backward => {
text_helpers::move_cursor_backward(&focus, paragraphs, word_boundary)
}
CursorDirection::Forward => {
text_helpers::move_cursor_forward(&focus, paragraphs, word_boundary)
}
CursorDirection::LineBefore => {
text_helpers::move_cursor_up(&focus, paragraphs, text_content)
}
CursorDirection::LineAfter => {
text_helpers::move_cursor_down(&focus, paragraphs, text_content)
}
CursorDirection::LineStart => text_helpers::move_cursor_line_start(&focus, paragraphs),
CursorDirection::LineEnd => text_helpers::move_cursor_line_end(&focus, paragraphs),
};
if extend_selection {
self.selection.extend_to(new_cursor);
} else {
self.selection.set_caret(new_cursor);
}
self.update_styles(text_content);
self.reset_blink();
self.push_event(TextEditorEvent::SelectionChanged);
true
}
}
fn is_word_char(c: char) -> bool {

View File

@ -36,12 +36,44 @@ impl From<RawFillData> for shapes::Fill {
}
}
impl TryFrom<&shapes::Fill> for RawFillData {
type Error = String;
fn try_from(fill: &shapes::Fill) -> Result<Self, Self::Error> {
match fill {
shapes::Fill::Solid(shapes::SolidColor(color)) => {
Ok(RawFillData::Solid(solid::RawSolidData {
color: ((color.a() as u32) << 24)
| ((color.r() as u32) << 16)
| ((color.g() as u32) << 8)
| (color.b() as u32),
}))
}
shapes::Fill::LinearGradient(_) => {
Err("LinearGradient serialization is not implemented".to_string())
}
shapes::Fill::RadialGradient(_) => {
Err("RadialGradient serialization is not implemented".to_string())
}
shapes::Fill::Image(_) => {
Err("Image fill serialization is not implemented".to_string())
}
}
}
}
impl From<[u8; RAW_FILL_DATA_SIZE]> for RawFillData {
fn from(bytes: [u8; RAW_FILL_DATA_SIZE]) -> Self {
unsafe { std::mem::transmute(bytes) }
}
}
impl From<RawFillData> for [u8; RAW_FILL_DATA_SIZE] {
fn from(fill_data: RawFillData) -> Self {
unsafe { std::mem::transmute(fill_data) }
}
}
impl TryFrom<&[u8]> for RawFillData {
type Error = String;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
@ -54,7 +86,7 @@ impl TryFrom<&[u8]> for RawFillData {
}
// FIXME: return Result
pub fn parse_fills_from_bytes(buffer: &[u8], num_fills: usize) -> Vec<shapes::Fill> {
pub fn read_fills_from_bytes(buffer: &[u8], num_fills: usize) -> Vec<shapes::Fill> {
buffer
.chunks_exact(RAW_FILL_DATA_SIZE)
.take(num_fills)
@ -74,7 +106,7 @@ pub extern "C" fn set_shape_fills() -> Result<()> {
// The first byte contains the actual number of fills
let num_fills = bytes.first().copied().unwrap_or(0) as usize;
// Skip the first 4 bytes (header with fill count) and parse only the actual fills
let fills = parse_fills_from_bytes(&bytes[4..], num_fills);
let fills = read_fills_from_bytes(&bytes[4..], num_fills);
shape.set_fills(fills);
mem::free_bytes()?;
});

View File

@ -1,4 +1,4 @@
use crate::shapes::{Paragraph, TextContent, TextPositionWithAffinity};
use crate::shapes::{Paragraph, TextContent, TextDirection, TextPositionWithAffinity};
use crate::state::TextSelection;
/// Get total character count in a paragraph.
@ -10,11 +10,11 @@ pub fn paragraph_char_count(para: &Paragraph) -> usize {
}
/// Get the text direction of the span at a given offset in a paragraph.
pub fn get_span_text_direction_at_offset(
pub fn get_text_span_text_direction_at_offset(
para: &Paragraph,
char_offset: usize,
) -> skia_safe::textlayout::TextDirection {
if let Some((span_idx, _)) = find_span_at_offset(para, char_offset) {
) -> TextDirection {
if let Some((span_idx, _)) = find_text_span_at_offset(para, char_offset) {
if let Some(span) = para.children().get(span_idx) {
return span.text_direction;
}
@ -258,7 +258,7 @@ pub fn paragraph_text_char_at(para: &Paragraph, offset: usize) -> Option<char> {
None
}
pub fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, usize)> {
pub fn find_text_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, usize)> {
let children = para.children();
let mut accumulated = 0;
for (span_idx, span) in children.iter().enumerate() {
@ -338,7 +338,7 @@ pub fn insert_text_at_cursor(
return Some(text.chars().count());
}
let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.offset)?;
let (span_idx, offset_in_span) = find_text_span_at_offset(para, cursor.offset)?;
let children = para.children_mut();
let span = &mut children[span_idx];
@ -690,7 +690,7 @@ pub fn split_paragraph_at_cursor(
let para = &paragraphs[cursor.paragraph];
let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else {
let Some((span_idx, offset_in_span)) = find_text_span_at_offset(para, cursor.offset) else {
return false;
};

View File

@ -4,13 +4,16 @@ use crate::math::{Matrix, Point, Rect};
use crate::mem;
use crate::render::text_editor as text_editor_render;
use crate::render::SurfaceId;
use crate::shapes::{Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
use crate::state::TextSelection;
use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
use crate::state::{TextEditorEvent, TextSelection};
use crate::utils::uuid_from_u32_quartet;
use crate::utils::uuid_to_u32_quartet;
use crate::wasm::text::helpers as text_helpers;
use crate::wasm::fills::RawFillData;
use crate::wasm::text::{
helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform,
};
use crate::{with_state, with_state_mut, STATE};
use skia_safe::{textlayout::TextDirection, Color};
use skia_safe::Color;
#[derive(PartialEq, ToJs)]
#[repr(u8)]
@ -194,6 +197,7 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
state.text_editor_state.start_pointer_selection();
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
state.text_editor_state.set_caret_from_position(&position);
state.text_editor_state.update_styles(text_content);
}
});
}
@ -222,6 +226,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
state
.text_editor_state
.extend_selection_from_position(&position);
state.text_editor_state.update_styles(text_content);
}
});
}
@ -249,6 +254,7 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
state
.text_editor_state
.extend_selection_from_position(&position);
state.text_editor_state.update_styles(text_content);
}
state.text_editor_state.stop_pointer_selection();
});
@ -312,7 +318,7 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
#[wasm_error]
pub extern "C" fn text_editor_composition_start() -> Result<()> {
with_state_mut!(state, {
if !state.text_editor_state.is_active {
if !state.text_editor_state.has_focus {
return Ok(());
}
state.text_editor_state.composition.start();
@ -331,7 +337,7 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> {
};
with_state_mut!(state, {
if !state.text_editor_state.is_active {
if !state.text_editor_state.has_focus {
return Ok(());
}
@ -392,7 +398,7 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
};
with_state_mut!(state, {
if !state.text_editor_state.is_active {
if !state.text_editor_state.has_focus {
return Ok(());
}
@ -486,10 +492,10 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::ContentChanged);
.push_event(TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout);
.push_event(TextEditorEvent::NeedsLayout);
state.render_state.mark_touched(shape_id);
});
@ -517,36 +523,9 @@ pub extern "C" fn text_editor_delete_backward(word_boundary: bool) {
return;
};
let selection = state.text_editor_state.selection;
if selection.is_selection() {
text_helpers::delete_selection_range(text_content, &selection);
let start = selection.start();
let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs());
state.text_editor_state.selection.set_caret(clamped);
} else if word_boundary {
let cursor = selection.focus;
if let Some(new_cursor) = text_helpers::delete_word_before(text_content, &cursor) {
state.text_editor_state.selection.set_caret(new_cursor);
}
} else {
let cursor = selection.focus;
if let Some(new_cursor) = text_helpers::delete_char_before(text_content, &cursor) {
state.text_editor_state.selection.set_caret(new_cursor);
}
}
text_content.layout.paragraphs.clear();
text_content.layout.paragraph_builders.clear();
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout);
.delete_backward(text_content, word_boundary);
state.render_state.mark_touched(shape_id);
});
}
@ -570,36 +549,9 @@ pub extern "C" fn text_editor_delete_forward(word_boundary: bool) {
return;
};
let selection = state.text_editor_state.selection;
if selection.is_selection() {
text_helpers::delete_selection_range(text_content, &selection);
let start = selection.start();
let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs());
state.text_editor_state.selection.set_caret(clamped);
} else if word_boundary {
let cursor = selection.focus;
text_helpers::delete_word_after(text_content, &cursor);
let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs());
state.text_editor_state.selection.set_caret(clamped);
} else {
let cursor = selection.focus;
text_helpers::delete_char_after(text_content, &cursor);
let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs());
state.text_editor_state.selection.set_caret(clamped);
}
text_content.layout.paragraphs.clear();
text_content.layout.paragraph_builders.clear();
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout);
.delete_forward(text_content, word_boundary);
state.render_state.mark_touched(shape_id);
});
}
@ -623,33 +575,7 @@ pub extern "C" fn text_editor_insert_paragraph() {
return;
};
let selection = state.text_editor_state.selection;
if selection.is_selection() {
text_helpers::delete_selection_range(text_content, &selection);
let start = selection.start();
state.text_editor_state.selection.set_caret(start);
}
let cursor = state.text_editor_state.selection.focus;
if text_helpers::split_paragraph_at_cursor(text_content, &cursor) {
let new_cursor =
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0);
state.text_editor_state.selection.set_caret(new_cursor);
}
text_content.layout.paragraphs.clear();
text_content.layout.paragraph_builders.clear();
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::ContentChanged);
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::NeedsLayout);
state.text_editor_state.insert_paragraph(text_content);
state.render_state.mark_touched(shape_id);
});
}
@ -681,63 +607,12 @@ pub extern "C" fn text_editor_move_cursor(
return;
};
let paragraphs = text_content.paragraphs();
if paragraphs.is_empty() {
return;
}
let current = state.text_editor_state.selection.focus;
// Get the text direction of the span at the current cursor position
let span_text_direction = if current.paragraph < paragraphs.len() {
text_helpers::get_span_text_direction_at_offset(
&paragraphs[current.paragraph],
current.offset,
)
} else {
TextDirection::LTR
};
// For horizontal navigation, swap Backward/Forward when in RTL text
let adjusted_direction = if span_text_direction == TextDirection::RTL {
match direction {
CursorDirection::Backward => CursorDirection::Forward,
CursorDirection::Forward => CursorDirection::Backward,
other => other,
}
} else {
direction
};
let new_cursor = match adjusted_direction {
CursorDirection::Backward => {
text_helpers::move_cursor_backward(&current, paragraphs, word_boundary)
}
CursorDirection::Forward => {
text_helpers::move_cursor_forward(&current, paragraphs, word_boundary)
}
CursorDirection::LineBefore => {
text_helpers::move_cursor_up(&current, paragraphs, text_content)
}
CursorDirection::LineAfter => {
text_helpers::move_cursor_down(&current, paragraphs, text_content)
}
CursorDirection::LineStart => {
text_helpers::move_cursor_line_start(&current, paragraphs)
}
CursorDirection::LineEnd => text_helpers::move_cursor_line_end(&current, paragraphs),
};
if extend_selection {
state.text_editor_state.selection.extend_to(new_cursor);
} else {
state.text_editor_state.selection.set_caret(new_cursor);
}
state.text_editor_state.reset_blink();
state
.text_editor_state
.push_event(crate::state::TextEditorEvent::SelectionChanged);
state.text_editor_state.move_cursor(
text_content,
direction,
word_boundary,
extend_selection,
);
});
}
@ -779,6 +654,171 @@ pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
})
}
#[no_mangle]
pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
with_state_mut!(state, {
if !state.text_editor_state.has_focus {
return std::ptr::null_mut();
}
let Some(shape_id) = state.text_editor_state.active_shape_id else {
return std::ptr::null_mut();
};
let Some(shape) = state.shapes.get(&shape_id) else {
return std::ptr::null_mut();
};
let Type::Text(_text_content) = &shape.shape_type else {
return std::ptr::null_mut();
};
let styles = &state.text_editor_state.current_styles;
let vertical_align = match styles.vertical_align {
VerticalAlign::Top => 0_u32,
VerticalAlign::Center => 1_u32,
VerticalAlign::Bottom => 2_u32,
};
let text_align = styles
.text_align
.value()
.as_ref()
.map(|value| match value {
TextAlign::Left => RawTextAlign::Left as u32,
TextAlign::Start => RawTextAlign::Left as u32,
TextAlign::Center => RawTextAlign::Center as u32,
TextAlign::Right => RawTextAlign::Right as u32,
TextAlign::End => RawTextAlign::Right as u32,
TextAlign::Justify => RawTextAlign::Justify as u32,
})
.unwrap_or(0);
let text_direction = styles
.text_direction
.value()
.as_ref()
.map(|value| match value {
skia_safe::textlayout::TextDirection::LTR => RawTextDirection::Ltr as u32,
skia_safe::textlayout::TextDirection::RTL => RawTextDirection::Rtl as u32,
})
.unwrap_or(0);
let text_decoration = styles
.text_decoration
.value()
.as_ref()
.map(|value| {
if *value == skia_safe::textlayout::TextDecoration::UNDERLINE {
RawTextDecoration::Underline as u32
} else if *value == skia_safe::textlayout::TextDecoration::LINE_THROUGH {
RawTextDecoration::LineThrough as u32
} else if *value == skia_safe::textlayout::TextDecoration::OVERLINE {
RawTextDecoration::Overline as u32
} else {
RawTextDecoration::None as u32
}
})
.unwrap_or(RawTextDecoration::None as u32);
let text_transform = styles
.text_transform
.value()
.as_ref()
.map(|value| match value {
crate::shapes::TextTransform::Uppercase => RawTextTransform::Uppercase as u32,
crate::shapes::TextTransform::Lowercase => RawTextTransform::Lowercase as u32,
crate::shapes::TextTransform::Capitalize => RawTextTransform::Capitalize as u32,
})
.unwrap_or(RawTextTransform::None as u32);
let font_family_id = styles
.font_family
.value()
.as_ref()
.map(|value| {
let (a, b, c, d) = uuid_to_u32_quartet(&value.id());
[a, b, c, d]
})
.unwrap_or_default();
let font_family_weight = styles
.font_family
.value()
.as_ref()
.map(|value| value.weight())
.unwrap_or_default();
let font_family_style = styles
.font_family
.value()
.as_ref()
.map(|value| value.style() as u32)
.unwrap_or_default();
let font_size = styles.font_size.value().unwrap_or(0.0);
let font_weight = styles.font_weight.value().unwrap_or(0);
let line_height = styles.line_height.value().unwrap_or(0.0);
let letter_spacing = styles.letter_spacing.value().unwrap_or(0.0);
let mut font_variant_id = [0_u32; 4];
if let Some(value) = styles.font_variant_id.value().as_ref() {
let (a, b, c, d) = uuid_to_u32_quartet(value);
font_variant_id = [a, b, c, d];
}
let mut fill_bytes = Vec::new();
let mut fill_count: u32 = 0;
for fill in &styles.fills {
if let Ok(raw_fill) = RawFillData::try_from(fill) {
fill_bytes
.extend_from_slice(&<[u8; std::mem::size_of::<RawFillData>()]>::from(raw_fill));
fill_count += 1;
}
}
// Layout: 48-byte fixed header + fixed values + serialized fills.
let mut bytes = Vec::with_capacity(132 + fill_bytes.len());
bytes.extend_from_slice(&vertical_align.to_le_bytes());
bytes.extend_from_slice(&(*styles.text_align.state() as u32).to_le_bytes());
bytes.extend_from_slice(&(*styles.text_direction.state() as u32).to_le_bytes());
bytes.extend_from_slice(&(*styles.text_decoration.state() as u32).to_le_bytes());
bytes.extend_from_slice(&(*styles.text_transform.state() as u32).to_le_bytes());
bytes.extend_from_slice(&(*styles.font_family.state() as u32).to_le_bytes());
bytes.extend_from_slice(&(*styles.font_size.state() as u32).to_le_bytes());
bytes.extend_from_slice(&(*styles.font_weight.state() as u32).to_le_bytes());
bytes.extend_from_slice(&(*styles.font_variant_id.state() as u32).to_le_bytes());
bytes.extend_from_slice(&(*styles.line_height.state() as u32).to_le_bytes());
bytes.extend_from_slice(&(*styles.letter_spacing.state() as u32).to_le_bytes());
bytes.extend_from_slice(&fill_count.to_le_bytes());
// Value section.
bytes.extend_from_slice(&text_align.to_le_bytes());
bytes.extend_from_slice(&text_direction.to_le_bytes());
bytes.extend_from_slice(&text_decoration.to_le_bytes());
bytes.extend_from_slice(&text_transform.to_le_bytes());
bytes.extend_from_slice(&font_family_id[0].to_le_bytes());
bytes.extend_from_slice(&font_family_id[1].to_le_bytes());
bytes.extend_from_slice(&font_family_id[2].to_le_bytes());
bytes.extend_from_slice(&font_family_id[3].to_le_bytes());
bytes.extend_from_slice(&font_family_style.to_le_bytes());
bytes.extend_from_slice(&font_family_weight.to_le_bytes());
bytes.extend_from_slice(&font_size.to_le_bytes());
bytes.extend_from_slice(&font_weight.to_le_bytes());
bytes.extend_from_slice(&font_variant_id[0].to_le_bytes());
bytes.extend_from_slice(&font_variant_id[1].to_le_bytes());
bytes.extend_from_slice(&font_variant_id[2].to_le_bytes());
bytes.extend_from_slice(&font_variant_id[3].to_le_bytes());
bytes.extend_from_slice(&line_height.to_le_bytes());
bytes.extend_from_slice(&letter_spacing.to_le_bytes());
bytes.extend_from_slice(&fill_bytes);
mem::write_bytes(bytes)
})
}
#[no_mangle]
pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 {
with_state_mut!(state, {
@ -804,7 +844,6 @@ pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 {
let selection = &state.text_editor_state.selection;
let rects = get_selection_rects(text_content, selection, shape);
if rects.is_empty() {
return std::ptr::null_mut();
}