diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc
index 90ed0930a2..516789428b 100644
--- a/common/src/app/common/features.cljc
+++ b/common/src/app/common/features.cljc
@@ -55,6 +55,7 @@
"design-tokens/v1"
"text-editor/v2-html-paste"
"text-editor/v2"
+ "text-editor-wasm/v1"
"render-wasm/v1"
"variants/v1"})
@@ -78,6 +79,7 @@
"plugins/runtime"
"text-editor/v2-html-paste"
"text-editor/v2"
+ "text-editor-wasm/v1"
"tokens/numeric-input"
"render-wasm/v1"})
@@ -127,6 +129,7 @@
:feature-design-tokens "design-tokens/v1"
:feature-text-editor-v2 "text-editor/v2"
:feature-text-editor-v2-html-paste "text-editor/v2-html-paste"
+ :feature-text-editor-wasm "text-editor-wasm/v1"
:feature-render-wasm "render-wasm/v1"
:feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input"
diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs
index 62fb30e2ec..34a5a57328 100644
--- a/frontend/src/app/main/data/workspace/texts.cljs
+++ b/frontend/src/app/main/data/workspace/texts.cljs
@@ -32,6 +32,7 @@
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.router :as rt]
+ [app.render-wasm.api :as wasm.api]
[app.util.text-editor :as ted]
[app.util.text.content.styles :as styles]
[app.util.timers :as ts]
@@ -777,7 +778,20 @@
(rx/of (v2-update-text-editor-styles id attrs)))
(when (features/active-feature? state "render-wasm/v1")
- (rx/of (dwwt/resize-wasm-text-debounce id)))))))
+ (rx/concat
+ ;; Apply style to selected spans and sync content
+ (when (wasm.api/text-editor-is-active?)
+ (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)]
+ (when result
+ (rx/of (v2-update-text-shape-content
+ (:shape-id result) (:content result)
+ :update-name? true)))))))
+ ;; Resize (with delay for font-id changes)
+ (cond->> (rx/of (dwwt/resize-wasm-text id))
+ (contains? attrs :font-id)
+ (rx/delay 200))))))))
ptk/EffectEvent
(effect [_ state _]
diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs
index d23b376844..fe5c4c36e3 100644
--- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs
+++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs
@@ -352,11 +352,9 @@
max-height (max height selrect-height)
valign (-> shape :content :vertical-align)
y (:y selrect)
- y (if (and valign (> height selrect-height))
- (case valign
- "bottom" (- y (- height selrect-height))
- "center" (- y (/ (- height selrect-height) 2))
- y)
+ y (case valign
+ "bottom" (+ y (- selrect-height height))
+ "center" (+ y (/ (- selrect-height height) 2))
y)]
[(assoc selrect :y y :width max-width :height max-height) transform])
diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs
index 902c8f860b..e45482fb57 100644
--- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs
@@ -19,10 +19,14 @@
[app.main.data.workspace.media :as dwm]
[app.main.data.workspace.path :as dwdp]
[app.main.data.workspace.specialized-panel :as-alias dwsp]
+ [app.main.data.workspace.texts :as dwt]
+ [app.main.features :as features]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.workspace.sidebar.assets.components :as wsac]
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
+ [app.render-wasm.api :as wasm.api]
+ [app.render-wasm.wasm :as wasm.wasm]
[app.util.dom :as dom]
[app.util.dom.dnd :as dnd]
[app.util.dom.normalize-wheel :as nw]
@@ -91,7 +95,17 @@
::dwsp/interrupt)
(when (and (not= edition id) (or text-editing? grid-editing?))
- (st/emit! (dw/clear-edition-mode)))
+ (st/emit! (dw/clear-edition-mode))
+ ;; Sync and stop WASM text editor when exiting edit mode
+ (when (and text-editing?
+ (features/active-feature? @st/state "render-wasm/v1")
+ wasm.wasm/context-initialized?)
+ (when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)]
+ (st/emit! (dwt/v2-update-text-shape-content
+ shape-id content
+ :update-name? true
+ :finalize? true)))
+ (wasm.api/text-editor-stop)))
(when (and (not text-editing?)
(not blocked)
@@ -184,6 +198,20 @@
(not drawing-tool))
(st/emit! (dw/select-shape (:id @hover) shift?)))
+ ;; If clicking on a text shape and wasm render is enabled, forward cursor position
+ (when (and hovering?
+ (not @space?)
+ edition ;; Only when already in edit mode
+ (not drawing-path?)
+ (not drawing-tool))
+ (let [hover-shape @hover]
+ (when (and (= :text (:type hover-shape))
+ (features/active-feature? @st/state "text-editor-wasm/v1")
+ wasm.wasm/context-initialized?)
+ (let [raw-pt (dom/get-client-position event)]
+ ;; FIXME
+ (wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt))))))
+
(when (and @z?
(not @space?)
(not edition)
@@ -223,8 +251,15 @@
(when (and (not drawing-path?) shape)
(cond
(and editable? (not= id edition) (not read-only?))
- (st/emit! (dw/select-shape id)
- (dw/start-editing-selected))
+ (do
+ (st/emit! (dw/select-shape id)
+ (dw/start-editing-selected))
+ ;; If using wasm text-editor, notify WASM to start editing this shape
+ ;; and set cursor position from the double-click location
+ (when (and (= type :text)
+ (features/active-feature? @st/state "text-editor-wasm/v1")
+ wasm.wasm/context-initialized?)
+ (wasm.api/text-editor-start id)))
(some? selected-shape)
(do
diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs
index 922e18057d..b615fc4aa7 100644
--- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs
@@ -164,7 +164,6 @@
;; for the release of the z key
(when-not ^boolean value
(reset! z* false))))
-
(hooks/use-stream kbd-zoom-s
(fn [kevent]
(dom/prevent-default kevent)
@@ -316,7 +315,7 @@
(and (cfh/group-shape? objects %)
(not (contains? child-parent? %)))
(and (features/active-feature? @st/state "render-wasm/v1")
- (cfh/text-shape? objects %)
+ (cfh/text-shape? (get objects %))
(not (wasm.api/intersect-position-in-shape % @last-point-ref)))))))
remove-measure-xf
diff --git a/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs b/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs
index 4ba3d44fea..41894c8d21 100644
--- a/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/viewport_ref.cljs
@@ -66,6 +66,14 @@
(gpt/divide zoom)
(gpt/add box))))))
+(defn point->viewport-relative
+ "Convert client coordinates to viewport-relative coordinates.
+ Unlike point->viewport, this does NOT convert to canvas coordinates -
+ it just subtracts the viewport's bounding rect offset."
+ [pt]
+ (when (some? @viewport-brect)
+ (gpt/subtract pt @viewport-brect)))
+
(defn inside-viewport?
[target]
(dom/is-child? @viewport-ref target))
diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
index c86d80f998..3edbe19c21 100644
--- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs
@@ -54,6 +54,7 @@
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.widgets :as widgets]
[app.render-wasm.api :as wasm.api]
+ [app.render-wasm.text-editor-input :refer [text-editor-input]]
[app.util.debug :as dbg]
[app.util.text-editor :as ted]
[beicon.v2.core :as rx]
@@ -407,7 +408,14 @@
(when picking-color?
[:> pixel-overlay/pixel-overlay-wasm* {:viewport-ref viewport-ref
- :canvas-ref canvas-ref}])]
+ :canvas-ref canvas-ref}])
+
+ ;; WASM text editor contenteditable (must be outside SVG to work)
+ (when (and show-text-editor?
+ (features/active-feature? @st/state "text-editor-wasm/v1"))
+ [:& text-editor-input {:shape editing-shape
+ :zoom zoom
+ :vbox vbox}])]
[:canvas {:id "render"
:data-testid "canvas-wasm-shapes"
@@ -452,7 +460,10 @@
:height (max 0 (- (:height vbox) rule-area-size))}]]]
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
- (when show-text-editor?
+ ;; Text editor handling:
+ ;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
+ (when (and show-text-editor?
+ (not (features/active-feature? @st/state "text-editor-wasm/v1")))
(if (features/active-feature? @st/state "text-editor/v2")
[:& editor-v2/text-editor {:shape editing-shape
:canvas-ref canvas-ref
diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs
index 6e2373b519..46a32ef16e 100644
--- a/frontend/src/app/render_wasm/api.cljs
+++ b/frontend/src/app/render_wasm/api.cljs
@@ -39,6 +39,7 @@
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters]
+ [app.render-wasm.text-editor :as text-editor]
[app.render-wasm.wasm :as wasm]
[app.util.debug :as dbg]
[app.util.dom :as dom]
@@ -74,6 +75,18 @@
;; Threshold below which we use synchronous processing (no chunking overhead)
(def ^:const ASYNC_THRESHOLD 100)
+;; Re-export public WebGL functions
+(def capture-canvas-pixels webgl/capture-canvas-pixels)
+(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
+(def clear-canvas-pixels webgl/clear-canvas-pixels)
+
+;; Re-export public text editor functions
+(def text-editor-start text-editor/text-editor-start)
+(def text-editor-stop text-editor/text-editor-stop)
+(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
+(def text-editor-is-active? text-editor/text-editor-is-active?)
+(def text-editor-sync-content text-editor/text-editor-sync-content)
+
(def dpr
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
@@ -109,11 +122,36 @@
(mf/element object-svg #js {:shape shape})
(rds/renderToStaticMarkup)))
+;; forward declare helpers so render can call them
+(declare request-render)
+(declare set-shape-vertical-align fonts-from-text-content)
+
;; This should never be called from the outside.
(defn- render
[timestamp]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render" timestamp)
+
+ ;; Update text editor blink (so cursor toggles) using the same timestamp
+ (try
+ (when wasm/context-initialized?
+ (text-editor/text-editor-update-blink timestamp)
+ ;; 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.
+ (let [runtime-features (get @st/state :features-runtime)
+ enabled-features (get @st/state :features)]
+ (when (or (contains? runtime-features "text-editor-wasm/v1")
+ (contains? enabled-features "text-editor-wasm/v1"))
+ (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"))))
+ (catch :default e
+ (js/console.error "text-editor overlay/update failed:" e)))
+
(set! wasm/internal-frame-id nil)
(ug/dispatch! (ug/event "penpot:wasm:render"))))
@@ -187,25 +225,6 @@
(declare get-text-dimensions)
-(defn update-text-rect!
- [id]
- (when wasm/context-initialized?
- (let [dimensions (get-text-dimensions id)
- page-id (:current-page-id @st/state)]
- (mw/emit!
- {:cmd :index/update-text-rect
- :page-id page-id
- :shape-id id
- :dimensions dimensions}))))
-
-
-(defn- ensure-text-content
- "Guarantee that the shape always sends a valid text tree to WASM. When the
- content is nil (freshly created text) we fall back to
- tc/default-text-content so the renderer receives typography information."
- [content]
- (or content (tc/v2-default-text-content)))
-
(defn use-shape
[id]
(when wasm/context-initialized?
@@ -216,6 +235,47 @@
(aget buffer 2)
(aget buffer 3)))))
+(defn set-shape-text-content
+ "This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
+ [shape-id content]
+
+ ;; Cache content for text editor sync
+ (text-editor/cache-shape-text-content! shape-id content)
+
+ (h/call wasm/internal-module "_clear_shape_text")
+
+ (set-shape-vertical-align (get content :vertical-align))
+
+ (let [fonts (f/get-content-fonts content)
+ fallback-fonts (fonts-from-text-content content true)
+ all-fonts (concat fonts fallback-fonts)
+ result (f/store-fonts all-fonts)]
+ (f/load-fallback-fonts-for-editor! fallback-fonts)
+ (h/call wasm/internal-module "_update_shape_text_layout")
+ result))
+
+(defn apply-style-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))
+
+(defn update-text-rect!
+ [id]
+ (when wasm/context-initialized?
+ (mw/emit!
+ {:cmd :index/update-text-rect
+ :page-id (:current-page-id @st/state)
+ :shape-id id
+ :dimensions (get-text-dimensions id)})))
+
+(defn- ensure-text-content
+ "Guarantee that the shape always sends a valid text tree to WASM. When the
+ content is nil (freshly created text) we fall back to
+ tc/default-text-content so the renderer receives typography information."
+ [content]
+ (or content (tc/v2-default-text-content)))
+
(defn set-parent-id
[id]
(let [buffer (uuid/get-u32 id)]
@@ -859,22 +919,6 @@
(if fallback-fonts-only? updated-fonts fallback-fonts))))))
-(defn set-shape-text-content
- "This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
- [shape-id content]
-
- (h/call wasm/internal-module "_clear_shape_text")
-
- (set-shape-vertical-align (get content :vertical-align))
-
- (let [fonts (f/get-content-fonts content)
- fallback-fonts (fonts-from-text-content content true)
- all-fonts (concat fonts fallback-fonts)
- result (f/store-fonts all-fonts)]
- (f/load-fallback-fonts-for-editor! fallback-fonts)
- (f/update-text-layout shape-id)
- result))
-
(defn set-shape-grow-type
[grow-type]
(h/call wasm/internal-module "_set_shape_grow_type" (sr/translate-grow-type grow-type)))
@@ -1072,7 +1116,7 @@
(defn- set-objects-async
"Asynchronously process shapes in chunks, yielding to the browser between chunks.
Returns a promise that resolves when all shapes are processed.
-
+
Renders a preview only periodically during loading to show progress,
then does a full tile-based render at the end."
[shapes render-callback]
@@ -1557,33 +1601,41 @@
(persistent! result)))
result
- (->> result
- (mapv
- (fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
- (let [content (:content shape)
- element (-> content :children
- (get 0) :children ;; paragraph-set
- (get paragraph) :children ;; paragraph
- (get span))
- text (subs (:text element) start-pos end-pos)]
+ (into []
+ (keep
+ (fn [{:keys [paragraph span start-pos end-pos direction x y width height]}]
+ (let [content (:content shape)
+ element (-> content :children
+ (get 0) :children ;; paragraph-set
+ (get paragraph) :children ;; paragraph
+ (get span))
+ element-text (:text element)]
- (d/patch-object
- txt/default-text-attrs
- (d/without-nils
- {:x x
- :y (+ y height)
- :width width
- :height height
- :direction (dr/translate-direction direction)
- :font-family (get element :font-family)
- :font-size (get element :font-size)
- :font-weight (get element :font-weight)
- :text-transform (get element :text-transform)
- :text-decoration (get element :text-decoration)
- :letter-spacing (get element :letter-spacing)
- :font-style (get element :font-style)
- :fills (d/nilv (get element :fills) [{:fill-color "#000000"}])
- :text text}))))))]
+ ;; Add comprehensive nil-safety checks
+ (when (and element
+ element-text
+ (>= start-pos 0)
+ (<= end-pos (count element-text))
+ (<= start-pos end-pos))
+ (let [text (subs element-text start-pos end-pos)]
+ (d/patch-object
+ txt/default-text-attrs
+ (d/without-nils
+ {:x x
+ :y (+ y height)
+ :width width
+ :height height
+ :direction (dr/translate-direction direction)
+ :font-family (get element :font-family)
+ :font-size (get element :font-size)
+ :font-weight (get element :font-weight)
+ :text-transform (get element :text-transform)
+ :text-decoration (get element :text-decoration)
+ :letter-spacing (get element :letter-spacing)
+ :font-style (get element :font-style)
+ :fills (get element :fills)
+ :text text})))))))
+ result)]
(mem/free)
result)))
@@ -1617,7 +1669,4 @@
(p/resolved false)))))
(p/resolved false))))
-;; Re-export public WebGL functions
-(def capture-canvas-pixels webgl/capture-canvas-pixels)
-(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
-(def clear-canvas-pixels webgl/clear-canvas-pixels)
+
diff --git a/frontend/src/app/render_wasm/api/shared.js b/frontend/src/app/render_wasm/api/shared.js
index 172cb2a97d..e5456d91d2 100644
--- a/frontend/src/app/render_wasm/api/shared.js
+++ b/frontend/src/app/render_wasm/api/shared.js
@@ -240,3 +240,12 @@ export const RawGrowType = {
"auto-height": 2,
};
+export const CursorDirection = {
+ "backward": 0,
+ "forward": 1,
+ "line-before": 2,
+ "line-after": 3,
+ "line-start": 4,
+ "line-end": 5,
+};
+
diff --git a/frontend/src/app/render_wasm/mem.cljs b/frontend/src/app/render_wasm/mem.cljs
index affccbc16c..4a9b7aa5e3 100644
--- a/frontend/src/app/render_wasm/mem.cljs
+++ b/frontend/src/app/render_wasm/mem.cljs
@@ -61,6 +61,29 @@
[]
(h/call wasm/internal-module "_free_bytes"))
+(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))))
+
(defn slice
"Returns a copy of a portion of a typed array into a new typed array
object selected from start to end."
diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs
new file mode 100644
index 0000000000..882f24f890
--- /dev/null
+++ b/frontend/src/app/render_wasm/text_editor.cljs
@@ -0,0 +1,300 @@
+;; 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.render-wasm.text-editor
+ "Text editor WASM bindings"
+ (:require
+ [app.common.uuid :as uuid]
+ [app.render-wasm.helpers :as h]
+ [app.render-wasm.mem :as mem]
+ [app.render-wasm.wasm :as wasm]))
+
+(defn text-editor-start
+ [id]
+ (when wasm/context-initialized?
+ (let [buffer (uuid/get-u32 id)]
+ (h/call wasm/internal-module "_text_editor_start"
+ (aget buffer 0)
+ (aget buffer 1)
+ (aget buffer 2)
+ (aget buffer 3)))))
+
+(defn text-editor-set-cursor-from-point
+ [x y]
+ (when wasm/context-initialized?
+ (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
+
+(defn text-editor-update-blink
+ [timestamp-ms]
+ (when wasm/context-initialized?
+ (h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms)))
+
+(defn text-editor-render-overlay
+ []
+ (when wasm/context-initialized?
+ (h/call wasm/internal-module "_text_editor_render_overlay")))
+
+(defn text-editor-poll-event
+ []
+ (when wasm/context-initialized?
+ (let [res (h/call wasm/internal-module "_text_editor_poll_event")]
+ res)))
+
+(defn text-editor-insert-text
+ [text]
+ (when wasm/context-initialized?
+ (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)
+ (h/call wasm/internal-module "_text_editor_insert_text")
+ (mem/free))))
+
+(defn text-editor-delete-backward []
+ (when wasm/context-initialized?
+ (h/call wasm/internal-module "_text_editor_delete_backward")))
+
+(defn text-editor-delete-forward []
+ (when wasm/context-initialized?
+ (h/call wasm/internal-module "_text_editor_delete_forward")))
+
+(defn text-editor-insert-paragraph []
+ (when wasm/context-initialized?
+ (h/call wasm/internal-module "_text_editor_insert_paragraph")))
+
+(defn text-editor-move-cursor
+ [direction extend-selection]
+ (when wasm/context-initialized?
+ (h/call wasm/internal-module "_text_editor_move_cursor" direction (if extend-selection 1 0))))
+
+(defn text-editor-select-all
+ []
+ (when wasm/context-initialized?
+ (h/call wasm/internal-module "_text_editor_select_all")))
+
+(defn text-editor-stop
+ []
+ (when wasm/context-initialized?
+ (h/call wasm/internal-module "_text_editor_stop")))
+
+(defn text-editor-is-active?
+ []
+ (when wasm/context-initialized?
+ (not (zero? (h/call wasm/internal-module "_text_editor_is_active")))))
+
+(defn text-editor-export-content
+ []
+ (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)]
+ (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/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)]
+ (mem/free)
+ text)))))
+
+(defn text-editor-get-active-shape-id
+ []
+ (when wasm/context-initialized?
+ (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/context-initialized?
+ (let [byte-offset (mem/alloc 16)
+ u32-offset (mem/->offset-32 byte-offset)
+ heap (mem/get-heap-u32)
+ active? (h/call wasm/internal-module "_text_editor_get_selection" byte-offset)]
+ (try
+ (when (= active? 1)
+ {: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))})
+ (finally
+ (mem/free))))))
+
+(def ^:private shape-text-contents (atom {}))
+
+(defn- merge-exported-texts-into-content
+ "Merge exported span texts back into the existing content tree.
+
+ The WASM editor may split or merge paragraphs (Enter / Backspace at
+ paragraph boundary), so the exported structure can differ from the
+ original. When extra paragraphs or spans appear we clone styling from
+ the nearest existing sibling; when fewer appear we truncate.
+
+ exported-texts vector of vectors [[\"span1\" \"span2\"] [\"p2s1\"]]
+ content existing Penpot content map (root -> paragraph-set -> …)"
+ [content exported-texts]
+ (let [para-set (first (get content :children))
+ orig-paras (get para-set :children)
+ num-orig (count orig-paras)
+ last-orig-para (when (seq orig-paras) (last orig-paras))
+ template-span (when last-orig-para
+ (-> last-orig-para :children last))
+ new-paras
+ (mapv (fn [para-idx exported-span-texts]
+ (let [orig-para (if (< para-idx num-orig)
+ (nth orig-paras para-idx)
+ (dissoc last-orig-para :children))
+ orig-spans (get orig-para :children)
+ num-orig-spans (count orig-spans)
+ last-orig-span (when (seq orig-spans) (last orig-spans))]
+ (assoc orig-para :children
+ (mapv (fn [span-idx new-text]
+ (let [orig-span (if (< span-idx num-orig-spans)
+ (nth orig-spans span-idx)
+ (or last-orig-span template-span))]
+ (assoc orig-span :text new-text)))
+ (range (count exported-span-texts))
+ exported-span-texts))))
+ (range (count exported-texts))
+ exported-texts)
+ new-para-set (assoc para-set :children new-paras)]
+ (assoc content :children [new-para-set])))
+
+(defn 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/context-initialized? (text-editor-is-active?))
+ (let [shape-id (text-editor-get-active-shape-id)
+ new-texts (text-editor-export-content)]
+ (when (and shape-id new-texts)
+ (let [texts-clj (js->clj new-texts)
+ content (get @shape-text-contents shape-id)]
+ (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 cache-shape-text-content!
+ [shape-id content]
+ (when (some? content)
+ (swap! shape-text-contents assoc shape-id content)))
+
+(defn get-cached-content
+ [shape-id]
+ (get @shape-text-contents shape-id))
+
+(defn update-cached-content!
+ [shape-id content]
+ (swap! shape-text-contents assoc shape-id content))
+
+(defn- normalize-selection
+ "Given anchor/focus para+offset, return {:start-para :start-offset :end-para :end-offset}
+ ordered so start <= end."
+ [{:keys [anchor-para anchor-offset focus-para focus-offset]}]
+ (if (or (< anchor-para focus-para)
+ (and (= anchor-para focus-para) (<= anchor-offset focus-offset)))
+ {:start-para anchor-para :start-offset anchor-offset
+ :end-para focus-para :end-offset focus-offset}
+ {:start-para focus-para :start-offset focus-offset
+ :end-para anchor-para :end-offset anchor-offset}))
+
+(defn- apply-attrs-to-paragraph
+ "Apply attrs to spans within [sel-start, sel-end) char range of a single paragraph.
+ Splits spans at boundaries as needed."
+ [para sel-start sel-end attrs]
+ (let [spans (:children para)
+ result (loop [spans spans
+ pos 0
+ acc []]
+ (if (empty? spans)
+ acc
+ (let [span (first spans)
+ text (:text span)
+ span-len (count text)
+ span-end (+ pos span-len)
+ ol-start (max pos sel-start)
+ ol-end (min span-end sel-end)
+ has-overlap? (< ol-start ol-end)]
+ (if (not has-overlap?)
+ (recur (rest spans) span-end (conj acc span))
+ (let [before (when (> ol-start pos)
+ (assoc span :text (subs text 0 (- ol-start pos))))
+ selected (merge span attrs
+ {:text (subs text (- ol-start pos) (- ol-end pos))})
+ after (when (< ol-end span-end)
+ (assoc span :text (subs text (- ol-end pos))))]
+ (recur (rest spans) span-end
+ (-> acc
+ (into (keep identity [before selected after])))))))))]
+ (assoc para :children result)))
+
+(defn- para-char-count
+ [para]
+ (apply + (map (fn [span] (count (:text span))) (:children para))))
+
+(defn apply-style-to-selection
+ [attrs use-shape-fn set-shape-text-content-fn]
+ (when (and wasm/context-initialized? (text-editor-is-active?))
+ (let [shape-id (text-editor-get-active-shape-id)
+ sel (text-editor-get-selection)]
+ (when (and shape-id sel)
+ (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
+ (when (not collapsed?)
+ (mapv (fn [idx para]
+ (cond
+ (or (< idx start-para) (> idx end-para))
+ para
+ (= start-para end-para)
+ (apply-attrs-to-paragraph para start-offset end-offset attrs)
+ (= idx start-para)
+ (apply-attrs-to-paragraph para start-offset (para-char-count para) attrs)
+ (= idx end-para)
+ (apply-attrs-to-paragraph para 0 end-offset attrs)
+ :else
+ (apply-attrs-to-paragraph para 0 (para-char-count para) attrs)))
+ (range (count paras))
+ paras))
+ new-content (when new-paras
+ (assoc content :children
+ [(assoc para-set :children new-paras)]))]
+ (when new-content
+ (swap! shape-text-contents assoc 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}))))))))
diff --git a/frontend/src/app/render_wasm/text_editor_input.cljs b/frontend/src/app/render_wasm/text_editor_input.cljs
new file mode 100644
index 0000000000..f2979b935a
--- /dev/null
+++ b/frontend/src/app/render_wasm/text_editor_input.cljs
@@ -0,0 +1,240 @@
+;; 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.render-wasm.text-editor-input
+ "Contenteditable DOM element for WASM text editor input"
+ (:require
+ [app.common.geom.shapes :as gsh]
+ [app.main.data.workspace.texts :as dwt]
+ [app.main.store :as st]
+ [app.render-wasm.api :as wasm.api]
+ [app.render-wasm.text-editor :as text-editor]
+ [app.util.dom :as dom]
+ [app.util.object :as obj]
+ [cuerdas.core :as str]
+ [goog.events :as events]
+ [rumext.v2 :as mf])
+ (:import goog.events.EventType))
+
+(defn- sync-wasm-text-editor-content!
+ "Sync WASM text editor content back to the shape via the standard
+ commit pipeline. Called after every text-modifying input."
+ [& {:keys [finalize?]}]
+ (when-let [{:keys [shape-id content]} (text-editor/text-editor-sync-content)]
+ (st/emit! (dwt/v2-update-text-shape-content
+ shape-id content
+ :update-name? true
+ :finalize? finalize?))))
+
+(mf/defc text-editor-input
+ "Contenteditable element positioned over the text shape to capture input events."
+ {::mf/wrap-props false}
+ [props]
+ (let [shape (obj/get props "shape")
+ zoom (obj/get props "zoom")
+ vbox (obj/get props "vbox")
+
+ contenteditable-ref (mf/use-ref nil)
+ composing? (mf/use-state false)
+
+ ;; Calculate screen position from shape bounds
+ shape-bounds (gsh/shape->rect shape)
+ screen-x (* (- (:x shape-bounds) (:x vbox)) zoom)
+ screen-y (* (- (:y shape-bounds) (:y vbox)) zoom)
+ screen-w (* (:width shape-bounds) zoom)
+ screen-h (* (:height shape-bounds) zoom)]
+
+ ;; Focus contenteditable on mount
+ (mf/use-effect
+ (fn []
+ (when-let [node (mf/ref-val contenteditable-ref)]
+ (.focus node))
+ js/undefined))
+
+ ;; Animation loop for cursor blink
+ (mf/use-effect
+ (fn []
+ (let [raf-id (atom nil)
+ animate (fn animate []
+ (when (text-editor/text-editor-is-active?)
+ (wasm.api/request-render "cursor-blink")
+ (reset! raf-id (js/requestAnimationFrame animate))))]
+ (animate)
+ (fn []
+ (when @raf-id
+ (js/cancelAnimationFrame @raf-id))))))
+
+ ;; Document-level keydown handler for control keys
+ (mf/use-effect
+ (fn []
+ (let [on-doc-keydown
+ (fn [e]
+ (when (and (text-editor/text-editor-is-active?)
+ (not @composing?))
+ (let [key (.-key e)
+ ctrl? (or (.-ctrlKey e) (.-metaKey e))
+ shift? (.-shiftKey e)]
+ (cond
+ ;; Escape: finalize and stop
+ (= key "Escape")
+ (do
+ (dom/prevent-default e)
+ (sync-wasm-text-editor-content! :finalize? true)
+ (text-editor/text-editor-stop))
+
+ ;; Ctrl+A: select all (key is "a" or "A" depending on platform)
+ (and ctrl? (= (str/lower key) "a"))
+ (do
+ (dom/prevent-default e)
+ (text-editor/text-editor-select-all)
+ (wasm.api/request-render "text-select-all"))
+
+ ;; Enter
+ (= key "Enter")
+ (do
+ (dom/prevent-default e)
+ (text-editor/text-editor-insert-paragraph)
+ (sync-wasm-text-editor-content!)
+ (wasm.api/request-render "text-paragraph"))
+
+ ;; Backspace
+ (= key "Backspace")
+ (do
+ (dom/prevent-default e)
+ (text-editor/text-editor-delete-backward)
+ (sync-wasm-text-editor-content!)
+ (wasm.api/request-render "text-delete-backward"))
+
+ ;; Delete
+ (= key "Delete")
+ (do
+ (dom/prevent-default e)
+ (text-editor/text-editor-delete-forward)
+ (sync-wasm-text-editor-content!)
+ (wasm.api/request-render "text-delete-forward"))
+
+ ;; Arrow keys
+ (= key "ArrowLeft")
+ (do
+ (dom/prevent-default e)
+ (text-editor/text-editor-move-cursor 0 shift?)
+ (wasm.api/request-render "text-cursor-move"))
+
+ (= key "ArrowRight")
+ (do
+ (dom/prevent-default e)
+ (text-editor/text-editor-move-cursor 1 shift?)
+ (wasm.api/request-render "text-cursor-move"))
+
+ (= key "ArrowUp")
+ (do
+ (dom/prevent-default e)
+ (text-editor/text-editor-move-cursor 2 shift?)
+ (wasm.api/request-render "text-cursor-move"))
+
+ (= key "ArrowDown")
+ (do
+ (dom/prevent-default e)
+ (text-editor/text-editor-move-cursor 3 shift?)
+ (wasm.api/request-render "text-cursor-move"))
+
+ (= key "Home")
+ (do
+ (dom/prevent-default e)
+ (text-editor/text-editor-move-cursor 4 shift?)
+ (wasm.api/request-render "text-cursor-move"))
+
+ (= key "End")
+ (do
+ (dom/prevent-default e)
+ (text-editor/text-editor-move-cursor 5 shift?)
+ (wasm.api/request-render "text-cursor-move"))
+
+ ;; Let contenteditable handle text input via on-input
+ :else nil))))]
+ (events/listen js/document EventType.KEYDOWN on-doc-keydown true)
+ (fn []
+ (events/unlisten js/document EventType.KEYDOWN on-doc-keydown true)))))
+
+ ;; Composition and input events
+ (let [on-composition-start
+ (mf/use-fn
+ (fn [_event]
+ (reset! composing? true)))
+
+ on-composition-end
+ (mf/use-fn
+ (fn [^js event]
+ (reset! composing? false)
+ (let [data (.-data event)]
+ (when data
+ (text-editor/text-editor-insert-text data)
+ (sync-wasm-text-editor-content!)
+ (wasm.api/request-render "text-composition"))
+ (when-let [node (mf/ref-val contenteditable-ref)]
+ (set! (.-textContent node) "")))))
+
+ on-paste
+ (mf/use-fn
+ (fn [^js event]
+ (dom/prevent-default event)
+ (let [clipboard-data (.-clipboardData event)
+ text (.getData clipboard-data "text/plain")]
+ (when (and text (seq text))
+ (text-editor/text-editor-insert-text text)
+ (sync-wasm-text-editor-content!)
+ (wasm.api/request-render "text-paste"))
+ (when-let [node (mf/ref-val contenteditable-ref)]
+ (set! (.-textContent node) "")))))
+
+ on-copy
+ (mf/use-fn
+ (fn [^js event]
+ (when (text-editor/text-editor-is-active?)
+ (dom/prevent-default event)
+ (when (text-editor/text-editor-get-selection)
+ (let [text (text-editor/text-editor-export-selection)]
+ (.setData (.-clipboardData event) "text/plain" text))))))
+
+ on-input
+ (mf/use-fn
+ (fn [^js event]
+ (let [native-event (.-nativeEvent event)
+ input-type (.-inputType native-event)
+ data (.-data native-event)]
+ ;; Skip composition-related input events - composition-end handles those
+ (when (and (not @composing?)
+ (not= input-type "insertCompositionText"))
+ (when (and data (seq data))
+ (text-editor/text-editor-insert-text data)
+ (sync-wasm-text-editor-content!)
+ (wasm.api/request-render "text-input"))
+ (when-let [node (mf/ref-val contenteditable-ref)]
+ (set! (.-textContent node) ""))))))]
+
+ [:div
+ {:ref contenteditable-ref
+ :contentEditable true
+ :suppressContentEditableWarning true
+ :on-composition-start on-composition-start
+ :on-composition-end on-composition-end
+ :on-input on-input
+ :on-paste on-paste
+ :on-copy on-copy
+ ;; FIXME on-click
+ ;; :on-click on-click
+ :id "text-editor-wasm-input"
+ ;; FIXME
+ :style {:position "absolute"
+ :left (str screen-x "px")
+ :top (str screen-y "px")
+ :width (str screen-w "px")
+ :height (str screen-h "px")
+ :opacity 0
+ :overflow "hidden"
+ :white-space "pre"
+ :cursor "text"
+ :z-index 10}}])))
diff --git a/render-wasm/docs/text_editor.md b/render-wasm/docs/text_editor.md
new file mode 100644
index 0000000000..8b65fb1f22
--- /dev/null
+++ b/render-wasm/docs/text_editor.md
@@ -0,0 +1,217 @@
+# Text Editor Architecture
+
+## Overview (Simplified)
+
+```mermaid
+flowchart TB
+ subgraph Browser["Browser / DOM"]
+ CE[contenteditable]
+ Events[DOM Events]
+ end
+
+ subgraph CLJS["ClojureScript"]
+ InputHandler[text_editor_input.cljs]
+ Bindings[text_editor.cljs]
+ ContentCache[(content cache)]
+ end
+
+ subgraph WASM["WASM Boundary"]
+ FFI["_text_editor_* functions"]
+ end
+
+ subgraph Rust["Rust"]
+ subgraph StateModule["state/text_editor.rs"]
+ TES[TextEditorState]
+ Selection[TextSelection]
+ Cursor[TextCursor]
+ end
+
+ subgraph WASMImpl["wasm/text_editor.rs"]
+ StateOps[start / stop]
+ CursorOps[cursor / selection]
+ EditOps[insert / delete]
+ ExportOps[export content]
+ end
+
+ subgraph RenderMod["render/text_editor.rs"]
+ RenderOverlay[render_overlay]
+ end
+
+ Shapes[(ShapesPool)]
+ end
+
+ subgraph Skia["Skia"]
+ Canvas[Canvas]
+ Paragraph[Paragraph layout]
+ end
+
+ %% Flow
+ CE --> Events
+ Events --> InputHandler
+ InputHandler --> Bindings
+ Bindings --> FFI
+ FFI --> StateOps & CursorOps & EditOps & ExportOps
+
+ StateOps --> TES
+ CursorOps --> TES
+ EditOps --> TES
+ EditOps --> Shapes
+ ExportOps --> Shapes
+ TES --> Selection --> Cursor
+
+ RenderOverlay --> TES
+ RenderOverlay --> Shapes
+ Shapes --> Paragraph
+ RenderOverlay --> Canvas
+ Paragraph --> Canvas
+
+ ExportOps --> ContentCache
+ ContentCache --> InputHandler
+```
+
+---
+
+## Detailed Architecture
+
+```mermaid
+flowchart TB
+ subgraph Browser["Browser / DOM"]
+ CE[contenteditable element]
+ KeyEvents[keydown / keyup]
+ MouseEvents[mousedown / mousemove]
+ IME[compositionstart / end]
+ end
+
+ subgraph CLJS["ClojureScript Layer"]
+ subgraph InputMod["text_editor_input.cljs"]
+ EventHandler[Event Handler]
+ BlinkLoop[RAF Blink Loop]
+ SyncFn[sync-content!]
+ end
+
+ subgraph BindingsMod["text_editor.cljs"]
+ direction TB
+ StartStop[start / stop]
+ CursorFns[set-cursor / move]
+ SelectFns[select-all / extend]
+ EditFns[insert / delete]
+ ExportFns[export-content]
+ StyleFns[apply-style]
+ end
+
+ ContentCache[(shape-text-contents
atom)]
+ end
+
+ subgraph WASM["WASM Boundary"]
+ direction TB
+ FFI_State["_text_editor_start
_text_editor_stop
_text_editor_is_active"]
+ FFI_Cursor["_text_editor_set_cursor_from_point
_text_editor_move_cursor
_text_editor_select_all"]
+ FFI_Edit["_text_editor_insert_text
_text_editor_delete_backward
_text_editor_insert_paragraph"]
+ FFI_Query["_text_editor_export_content
_text_editor_get_selection
_text_editor_poll_event"]
+ FFI_Render["_text_editor_render_overlay
_text_editor_update_blink"]
+ end
+
+ subgraph Rust["Rust Layer"]
+ subgraph StateMod["state/text_editor.rs"]
+ TES[TextEditorState]
+ Selection[TextSelection]
+ Cursor[TextCursor]
+ Events[EditorEvent queue]
+ end
+
+ subgraph WASMMod["wasm/text_editor.rs"]
+ direction TB
+ WStateOps[State ops]
+ WCursorOps[Cursor ops]
+ WEditOps[Edit ops]
+ WQueryOps[Query ops]
+ end
+
+ subgraph RenderMod["render/text_editor.rs"]
+ RenderOverlay[render_overlay]
+ RenderCursor[render_cursor]
+ RenderSelection[render_selection]
+ end
+
+ Shapes[(ShapesPool
TextContent)]
+ end
+
+ subgraph Skia["Skia"]
+ Canvas[Canvas]
+ SkParagraph[textlayout::Paragraph]
+ TextBoxes[get_rects_for_range]
+ end
+
+ %% Browser to CLJS
+ CE --> KeyEvents & MouseEvents & IME
+ KeyEvents --> EventHandler
+ MouseEvents --> EventHandler
+ IME --> EventHandler
+
+ %% CLJS internal
+ EventHandler --> StartStop & CursorFns & EditFns & SelectFns
+ BlinkLoop --> FFI_Render
+ SyncFn --> ExportFns
+ ExportFns --> ContentCache
+ ContentCache --> SyncFn
+ StyleFns --> ContentCache
+
+ %% CLJS to WASM
+ StartStop --> FFI_State
+ CursorFns --> FFI_Cursor
+ SelectFns --> FFI_Cursor
+ EditFns --> FFI_Edit
+ ExportFns --> FFI_Query
+
+ %% WASM to Rust impl
+ FFI_State --> WStateOps
+ FFI_Cursor --> WCursorOps
+ FFI_Edit --> WEditOps
+ FFI_Query --> WQueryOps
+ FFI_Render --> RenderOverlay
+
+ %% Rust internal
+ WStateOps --> TES
+ WCursorOps --> TES
+ WEditOps --> TES
+ WEditOps --> Shapes
+ WQueryOps --> TES
+ WQueryOps --> Shapes
+
+ TES --> Selection
+ Selection --> Cursor
+ TES --> Events
+
+ %% Render flow
+ RenderOverlay --> RenderCursor & RenderSelection
+ RenderCursor --> TES
+ RenderSelection --> TES
+ RenderCursor --> Shapes
+ RenderSelection --> Shapes
+
+ %% Skia
+ Shapes --> SkParagraph
+ SkParagraph --> TextBoxes
+ RenderCursor --> Canvas
+ RenderSelection --> Canvas
+```
+
+---
+
+## Key Files
+
+| Layer | File | Purpose |
+|-------|------|---------|
+| DOM | - | contenteditable captures keyboard/IME input |
+| CLJS | `text_editor_input.cljs` | Event handling, blink loop, content sync |
+| CLJS | `text_editor.cljs` | WASM bindings, content cache, style application |
+| Rust | `state/text_editor.rs` | TextEditorState, TextSelection, TextCursor |
+| Rust | `wasm/text_editor.rs` | WASM exported functions |
+| Rust | `render/text_editor.rs` | Cursor & selection overlay rendering |
+
+## Data Flow
+
+1. **Input**: DOM events → ClojureScript handler → WASM function → Rust state
+2. **Edit**: Rust modifies TextContent in ShapesPool → triggers layout
+3. **Sync**: Export content → merge with cached styles → update shape
+4. **Render**: RAF loop → render_overlay → Skia draws cursor/selection
diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs
index 9a7b80c132..1766af5a34 100644
--- a/render-wasm/src/render.rs
+++ b/render-wasm/src/render.rs
@@ -10,6 +10,7 @@ mod shadows;
mod strokes;
mod surfaces;
pub mod text;
+pub mod text_editor;
mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect};
diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs
new file mode 100644
index 0000000000..be37ce627d
--- /dev/null
+++ b/render-wasm/src/render/text_editor.rs
@@ -0,0 +1,240 @@
+use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
+use crate::state::{TextEditorState, TextSelection};
+use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
+use skia_safe::{BlendMode, Canvas, Matrix, Paint, Rect};
+
+pub fn render_overlay(
+ canvas: &Canvas,
+ editor_state: &TextEditorState,
+ shape: &Shape,
+ transform: &Matrix,
+) {
+ if !editor_state.is_active {
+ return;
+ }
+
+ let Type::Text(text_content) = &shape.shape_type else {
+ return;
+ };
+
+ canvas.save();
+ canvas.concat(transform);
+
+ if editor_state.selection.is_selection() {
+ render_selection(canvas, editor_state, text_content, shape);
+ }
+
+ if editor_state.cursor_visible {
+ render_cursor(canvas, editor_state, text_content, shape);
+ }
+
+ canvas.restore();
+}
+
+fn render_cursor(
+ canvas: &Canvas,
+ editor_state: &TextEditorState,
+ text_content: &TextContent,
+ shape: &Shape,
+) {
+ let Some(rect) = calculate_cursor_rect(editor_state, text_content, shape) else {
+ return;
+ };
+
+ let mut paint = Paint::default();
+ paint.set_color(editor_state.theme.cursor_color);
+ paint.set_anti_alias(true);
+
+ canvas.draw_rect(rect, &paint);
+}
+
+fn render_selection(
+ canvas: &Canvas,
+ editor_state: &TextEditorState,
+ text_content: &TextContent,
+ shape: &Shape,
+) {
+ let selection = &editor_state.selection;
+ let rects = calculate_selection_rects(selection, text_content, shape);
+
+ if rects.is_empty() {
+ return;
+ }
+
+ let mut paint = Paint::default();
+ paint.set_blend_mode(BlendMode::Multiply);
+ paint.set_color(editor_state.theme.selection_color);
+ paint.set_anti_alias(true);
+ for rect in rects {
+ canvas.draw_rect(rect, &paint);
+ }
+}
+
+fn vertical_align_offset(
+ shape: &Shape,
+ layout_paragraphs: &[&skia_safe::textlayout::Paragraph],
+) -> f32 {
+ let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
+ match shape.vertical_align() {
+ VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0,
+ VerticalAlign::Bottom => shape.selrect().height() - total_height,
+ _ => 0.0,
+ }
+}
+
+fn calculate_cursor_rect(
+ editor_state: &TextEditorState,
+ text_content: &TextContent,
+ shape: &Shape,
+) -> Option {
+ let cursor = editor_state.selection.focus;
+ let paragraphs = text_content.paragraphs();
+ if cursor.paragraph >= paragraphs.len() {
+ return None;
+ }
+
+ let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
+
+ if cursor.paragraph >= layout_paragraphs.len() {
+ return None;
+ }
+
+ let selrect = shape.selrect();
+
+ let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
+ for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
+ if idx == cursor.paragraph {
+ let char_pos = cursor.char_offset;
+ // For cursor, we get a zero-width range at the position
+ // We need to handle edge cases:
+ // - At start of paragraph: use position 0
+ // - At end of paragraph: use last position
+ let para = ¶graphs[cursor.paragraph];
+ let para_char_count: usize = para
+ .children()
+ .iter()
+ .map(|span| span.text.chars().count())
+ .sum();
+
+ let (cursor_x, cursor_height) = if para_char_count == 0 {
+ // Empty paragraph - use default height
+ (0.0, laid_out_para.height())
+ } else if char_pos == 0 {
+ let rects = laid_out_para.get_rects_for_range(
+ 0..1,
+ RectHeightStyle::Max,
+ RectWidthStyle::Tight,
+ );
+ if !rects.is_empty() {
+ (rects[0].rect.left(), rects[0].rect.height())
+ } else {
+ (0.0, laid_out_para.height())
+ }
+ } else if char_pos >= para_char_count {
+ let rects = laid_out_para.get_rects_for_range(
+ para_char_count.saturating_sub(1)..para_char_count,
+ RectHeightStyle::Max,
+ RectWidthStyle::Tight,
+ );
+ if !rects.is_empty() {
+ (rects[0].rect.right(), rects[0].rect.height())
+ } else {
+ (laid_out_para.longest_line(), laid_out_para.height())
+ }
+ } else {
+ let rects = laid_out_para.get_rects_for_range(
+ char_pos..char_pos + 1,
+ RectHeightStyle::Max,
+ RectWidthStyle::Tight,
+ );
+ if !rects.is_empty() {
+ (rects[0].rect.left(), rects[0].rect.height())
+ } else {
+ // Fallback: use glyph position
+ let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
+ (pos.position as f32, laid_out_para.height())
+ }
+ };
+
+ return Some(Rect::from_xywh(
+ selrect.x() + cursor_x,
+ selrect.y() + y_offset,
+ editor_state.theme.cursor_width,
+ cursor_height,
+ ));
+ }
+ y_offset += laid_out_para.height();
+ }
+
+ None
+}
+
+fn calculate_selection_rects(
+ selection: &TextSelection,
+ text_content: &TextContent,
+ shape: &Shape,
+) -> Vec {
+ let mut rects = Vec::new();
+
+ let start = selection.start();
+ let end = selection.end();
+
+ let paragraphs = text_content.paragraphs();
+ let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
+
+ let selrect = shape.selrect();
+ let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
+
+ for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
+ let para_height = laid_out_para.height();
+
+ // Check if this paragraph is in selection range
+ if para_idx < start.paragraph || para_idx > end.paragraph {
+ y_offset += para_height;
+ continue;
+ }
+
+ // Calculate character range for this paragraph
+ let para = ¶graphs[para_idx];
+ let para_char_count: usize = para
+ .children()
+ .iter()
+ .map(|span| span.text.chars().count())
+ .sum();
+
+ let range_start = if para_idx == start.paragraph {
+ start.char_offset
+ } else {
+ 0
+ };
+
+ let range_end = if para_idx == end.paragraph {
+ end.char_offset
+ } else {
+ para_char_count
+ };
+
+ if range_start < range_end {
+ use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
+ let text_boxes = laid_out_para.get_rects_for_range(
+ range_start..range_end,
+ RectHeightStyle::Max,
+ RectWidthStyle::Tight,
+ );
+
+ for text_box in text_boxes {
+ let r = text_box.rect;
+ rects.push(Rect::from_xywh(
+ selrect.x() + r.left(),
+ selrect.y() + y_offset + r.top(),
+ r.width(),
+ r.height(),
+ ));
+ }
+ }
+
+ y_offset += para_height;
+ }
+
+ rects
+}
diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs
index 42cb6cd373..feaab039fb 100644
--- a/render-wasm/src/shapes/text.rs
+++ b/render-wasm/src/shapes/text.rs
@@ -116,6 +116,7 @@ impl TextContentSize {
pub struct TextPositionWithAffinity {
pub position_with_affinity: PositionWithAffinity,
pub paragraph: i32,
+ #[allow(dead_code)]
pub span: i32,
pub offset: i32,
}
@@ -316,6 +317,10 @@ impl TextContent {
&self.paragraphs
}
+ pub fn paragraphs_mut(&mut self) -> &mut Vec {
+ &mut self.paragraphs
+ }
+
pub fn width(&self) -> f32 {
self.size.width
}
@@ -428,8 +433,16 @@ impl TextContent {
let end_y = offset_y + layout_paragraph.height();
// We only test against paragraphs that can contain the current y
- // coordinate.
- if point.y > start_y && point.y < end_y {
+ // coordinate. Use >= for start and handle zero-height paragraphs.
+ let paragraph_height = layout_paragraph.height();
+ let matches = if paragraph_height > 0.0 {
+ point.y >= start_y && point.y < end_y
+ } else {
+ // For zero-height paragraphs (empty lines), match if we're at the start position
+ point.y >= start_y && point.y <= start_y + 1.0
+ };
+
+ if matches {
let position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate(*point);
if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) {
@@ -438,18 +451,37 @@ impl TextContent {
// in which span we are.
let mut computed_position = 0;
let mut span_offset = 0;
- for span in paragraph.children() {
- span_index += 1;
- let length = span.text.len();
- let start_position = computed_position;
- let end_position = computed_position + length;
- let current_position = position_with_affinity.position as usize;
- if start_position <= current_position && end_position >= current_position {
- span_offset = position_with_affinity.position - start_position as i32;
- break;
+
+ // If paragraph has no spans, default to span 0, offset 0
+ if paragraph.children().is_empty() {
+ span_index = 0;
+ span_offset = 0;
+ } else {
+ for span in paragraph.children() {
+ span_index += 1;
+ let length = span.text.chars().count();
+ let start_position = computed_position;
+ let end_position = computed_position + length;
+ let current_position = position_with_affinity.position as usize;
+
+ // Handle empty spans: if the span is empty and current position
+ // matches the start, this is the right span
+ if length == 0 && current_position == start_position {
+ span_offset = 0;
+ break;
+ }
+
+ if start_position <= current_position
+ && end_position >= current_position
+ {
+ span_offset =
+ position_with_affinity.position - start_position as i32;
+ break;
+ }
+ computed_position += length;
}
- computed_position += length;
}
+
return Some(TextPositionWithAffinity::new(
position_with_affinity,
paragraph_index,
@@ -460,6 +492,26 @@ impl TextContent {
}
offset_y += layout_paragraph.height();
}
+
+ // Handle completely empty text shapes: if there are no paragraphs or all paragraphs
+ // are empty, and the click is within the text shape bounds, return a default position
+ if (self.paragraphs().is_empty() || self.layout.paragraphs.is_empty())
+ && self.bounds.contains(*point)
+ {
+ // Create a default position at the start of the text
+ use skia_safe::textlayout::Affinity;
+ let default_position = PositionWithAffinity {
+ position: 0,
+ affinity: Affinity::Downstream,
+ };
+ return Some(TextPositionWithAffinity::new(
+ default_position,
+ 0, // paragraph 0
+ 0, // span 0
+ 0, // offset 0
+ ));
+ }
+
None
}
@@ -838,6 +890,10 @@ impl Paragraph {
&self.children
}
+ pub fn children_mut(&mut self) -> &mut Vec {
+ &mut self.children
+ }
+
#[allow(dead_code)]
fn add_span(&mut self, span: TextSpan) {
self.children.push(span);
@@ -847,6 +903,26 @@ impl Paragraph {
self.line_height
}
+ pub fn letter_spacing(&self) -> f32 {
+ self.letter_spacing
+ }
+
+ pub fn text_align(&self) -> TextAlign {
+ self.text_align
+ }
+
+ pub fn text_direction(&self) -> TextDirection {
+ self.text_direction
+ }
+
+ pub fn text_decoration(&self) -> Option {
+ self.text_decoration
+ }
+
+ pub fn text_transform(&self) -> Option {
+ self.text_transform
+ }
+
pub fn paragraph_to_style(&self) -> ParagraphStyle {
let mut style = ParagraphStyle::default();
@@ -1228,14 +1304,21 @@ pub fn calculate_text_layout_data(
let current_y = para_layout.y;
let text_paragraph = text_paragraphs.get(paragraph_index);
if let Some(text_para) = text_paragraph {
- let mut span_ranges: Vec<(usize, usize, usize)> = vec![];
+ let mut span_ranges: Vec<(usize, usize, usize, String, String)> = vec![];
let mut cur = 0;
for (span_index, span) in text_para.children().iter().enumerate() {
- let text: String = span.apply_text_transform();
- span_ranges.push((cur, cur + text.len(), span_index));
- cur += text.len();
+ let transformed_text: String = span.apply_text_transform();
+ let original_text = span.text.clone();
+ let text = transformed_text.clone();
+ let text_len = text.len();
+ span_ranges.push((cur, cur + text_len, span_index, text, original_text));
+ cur += text_len;
}
- for (start, end, span_index) in span_ranges {
+ for (start, end, span_index, transformed_text, original_text) in span_ranges {
+ // Skip empty spans to avoid invalid rect calculations
+ if start >= end {
+ continue;
+ }
let rects = para_layout.paragraph.get_rects_for_range(
start..end,
RectHeightStyle::Tight,
@@ -1245,22 +1328,43 @@ pub fn calculate_text_layout_data(
let direction = textbox.direct;
let mut rect = textbox.rect;
let cy = rect.top + rect.height() / 2.0;
- let start_pos = para_layout
+
+ // Get byte positions from Skia's transformed text layout
+ let glyph_start = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.left + 0.1, cy))
.position as usize;
- let end_pos = para_layout
+ let glyph_end = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.right - 0.1, cy))
.position as usize;
- let start_pos = start_pos.saturating_sub(start);
- let end_pos = end_pos.saturating_sub(start);
+
+ // Convert to byte positions relative to this span
+ let byte_start = glyph_start.saturating_sub(start);
+ let byte_end = glyph_end.saturating_sub(start);
+
+ // Convert byte positions to character positions in ORIGINAL text
+ // This handles multi-byte UTF-8 and text transform differences
+ let char_start = transformed_text
+ .char_indices()
+ .position(|(i, _)| i >= byte_start)
+ .unwrap_or(0);
+ let char_end = transformed_text
+ .char_indices()
+ .position(|(i, _)| i >= byte_end)
+ .unwrap_or_else(|| transformed_text.chars().count());
+
+ // Clamp to original text length for safety
+ let original_char_count = original_text.chars().count();
+ let final_start = char_start.min(original_char_count);
+ let final_end = char_end.min(original_char_count);
+
rect.offset((x, current_y));
position_data.push(PositionData {
paragraph: paragraph_index as u32,
span: span_index as u32,
- start_pos: start_pos as u32,
- end_pos: end_pos as u32,
+ start_pos: final_start as u32,
+ end_pos: final_end as u32,
x: rect.x(),
y: rect.y(),
width: rect.width(),
diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs
index 1664b3bb2d..d7474cc92f 100644
--- a/render-wasm/src/state/text_editor.rs
+++ b/render-wasm/src/state/text_editor.rs
@@ -1,9 +1,226 @@
#![allow(dead_code)]
use crate::shapes::TextPositionWithAffinity;
+use crate::uuid::Uuid;
+use skia_safe::Color;
-/// TODO: Now this is just a tuple with 2 i32 working
-/// as indices (paragraph and span).
+/// Cursor position within text content.
+/// Uses character offsets for precise positioning.
+#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
+pub struct TextCursor {
+ pub paragraph: usize,
+ pub char_offset: usize,
+}
+
+impl TextCursor {
+ pub fn new(paragraph: usize, char_offset: usize) -> Self {
+ Self {
+ paragraph,
+ char_offset,
+ }
+ }
+
+ pub fn zero() -> Self {
+ Self {
+ paragraph: 0,
+ char_offset: 0,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+pub struct TextSelection {
+ pub anchor: TextCursor,
+ pub focus: TextCursor,
+}
+
+impl TextSelection {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn from_cursor(cursor: TextCursor) -> Self {
+ Self {
+ anchor: cursor,
+ focus: cursor,
+ }
+ }
+
+ pub fn is_collapsed(&self) -> bool {
+ self.anchor == self.focus
+ }
+
+ pub fn is_selection(&self) -> bool {
+ !self.is_collapsed()
+ }
+
+ pub fn set_caret(&mut self, cursor: TextCursor) {
+ self.anchor = cursor;
+ self.focus = cursor;
+ }
+
+ pub fn extend_to(&mut self, cursor: TextCursor) {
+ self.focus = cursor;
+ }
+
+ pub fn collapse_to_focus(&mut self) {
+ self.anchor = self.focus;
+ }
+
+ pub fn collapse_to_anchor(&mut self) {
+ self.focus = self.anchor;
+ }
+
+ pub fn start(&self) -> TextCursor {
+ if self.anchor.paragraph < self.focus.paragraph {
+ self.anchor
+ } else if self.anchor.paragraph > self.focus.paragraph {
+ self.focus
+ } else if self.anchor.char_offset <= self.focus.char_offset {
+ self.anchor
+ } else {
+ self.focus
+ }
+ }
+
+ pub fn end(&self) -> TextCursor {
+ if self.anchor.paragraph > self.focus.paragraph {
+ self.anchor
+ } else if self.anchor.paragraph < self.focus.paragraph {
+ self.focus
+ } else if self.anchor.char_offset >= self.focus.char_offset {
+ self.anchor
+ } else {
+ self.focus
+ }
+ }
+}
+
+/// Events that the text editor can emit for frontend synchronization
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[repr(u8)]
+pub enum EditorEvent {
+ None = 0,
+ ContentChanged = 1,
+ SelectionChanged = 2,
+ NeedsLayout = 3,
+}
+
+/// FIXME: It should be better to get these constants from the frontend through the API.
+const SELECTION_COLOR: Color = Color::from_argb(255, 0, 209, 184);
+const CURSOR_WIDTH: f32 = 1.5;
+const CURSOR_COLOR: Color = Color::BLACK;
+const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
+
+pub struct TextEditorTheme {
+ pub selection_color: Color,
+ pub cursor_width: f32,
+ pub cursor_color: Color,
+}
+
+pub struct TextEditorState {
+ pub theme: TextEditorTheme,
+ pub selection: TextSelection,
+ pub is_active: bool,
+ pub active_shape_id: Option,
+ pub cursor_visible: bool,
+ pub last_blink_time: f64,
+ pending_events: Vec,
+}
+
+impl TextEditorState {
+ pub fn new() -> Self {
+ Self {
+ theme: TextEditorTheme {
+ selection_color: SELECTION_COLOR,
+ cursor_width: CURSOR_WIDTH,
+ cursor_color: CURSOR_COLOR,
+ },
+ selection: TextSelection::new(),
+ is_active: false,
+ active_shape_id: None,
+ cursor_visible: true,
+ last_blink_time: 0.0,
+ pending_events: Vec::new(),
+ }
+ }
+
+ pub fn start(&mut self, shape_id: Uuid) {
+ self.is_active = true;
+ self.active_shape_id = Some(shape_id);
+ self.cursor_visible = true;
+ self.last_blink_time = 0.0;
+ self.selection = TextSelection::new();
+ self.pending_events.clear();
+ }
+
+ pub fn stop(&mut self) {
+ self.is_active = false;
+ self.active_shape_id = None;
+ self.cursor_visible = false;
+ self.pending_events.clear();
+ }
+
+ pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) {
+ let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
+ self.selection.set_caret(cursor);
+ self.reset_blink();
+ self.push_event(EditorEvent::SelectionChanged);
+ }
+
+ pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) {
+ let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
+ self.selection.extend_to(cursor);
+ self.reset_blink();
+ self.push_event(EditorEvent::SelectionChanged);
+ }
+
+ pub fn update_blink(&mut self, timestamp_ms: f64) {
+ if !self.is_active {
+ return;
+ }
+
+ if self.last_blink_time == 0.0 {
+ self.last_blink_time = timestamp_ms;
+ self.cursor_visible = true;
+ return;
+ }
+
+ let elapsed = timestamp_ms - self.last_blink_time;
+ if elapsed >= CURSOR_BLINK_INTERVAL_MS {
+ self.cursor_visible = !self.cursor_visible;
+ self.last_blink_time = timestamp_ms;
+ }
+ }
+
+ pub fn reset_blink(&mut self) {
+ self.cursor_visible = true;
+ self.last_blink_time = 0.0;
+ }
+
+ pub fn push_event(&mut self, event: EditorEvent) {
+ if self.pending_events.last() != Some(&event) {
+ self.pending_events.push(event);
+ }
+ }
+
+ pub fn poll_event(&mut self) -> EditorEvent {
+ self.pending_events.pop().unwrap_or(EditorEvent::None)
+ }
+
+ pub fn has_pending_events(&self) -> bool {
+ !self.pending_events.is_empty()
+ }
+
+ pub fn set_caret_position_from(
+ &mut self,
+ text_position_with_affinity: TextPositionWithAffinity,
+ ) {
+ self.set_caret_from_position(text_position_with_affinity);
+ }
+}
+
+/// TODO: Remove legacy code
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct TextNodePosition {
pub paragraph: i32,
@@ -15,89 +232,7 @@ impl TextNodePosition {
Self { paragraph, span }
}
- #[allow(dead_code)]
pub fn is_invalid(&self) -> bool {
self.paragraph < 0 || self.span < 0
}
}
-
-pub struct TextPosition {
- node: Option,
- offset: i32,
-}
-
-impl TextPosition {
- pub fn new() -> Self {
- Self {
- node: None,
- offset: -1,
- }
- }
-
- pub fn set(&mut self, node: Option, offset: i32) {
- self.node = node;
- self.offset = offset;
- }
-}
-
-pub struct TextSelection {
- focus: TextPosition,
- anchor: TextPosition,
-}
-
-impl TextSelection {
- pub fn new() -> Self {
- Self {
- focus: TextPosition::new(),
- anchor: TextPosition::new(),
- }
- }
-
- #[allow(dead_code)]
- pub fn is_caret(&self) -> bool {
- self.focus.node == self.anchor.node && self.focus.offset == self.anchor.offset
- }
-
- #[allow(dead_code)]
- pub fn is_selection(&self) -> bool {
- !self.is_caret()
- }
-
- pub fn set_focus(&mut self, node: Option, offset: i32) {
- self.focus.set(node, offset);
- }
-
- pub fn set_anchor(&mut self, node: Option, offset: i32) {
- self.anchor.set(node, offset);
- }
-
- pub fn set(&mut self, node: Option, offset: i32) {
- self.set_focus(node, offset);
- self.set_anchor(node, offset);
- }
-}
-
-pub struct TextEditorState {
- selection: TextSelection,
-}
-
-impl TextEditorState {
- pub fn new() -> Self {
- Self {
- selection: TextSelection::new(),
- }
- }
-
- pub fn set_caret_position_from(
- &mut self,
- text_position_with_affinity: TextPositionWithAffinity,
- ) {
- self.selection.set(
- Some(TextNodePosition::new(
- text_position_with_affinity.paragraph,
- text_position_with_affinity.span,
- )),
- text_position_with_affinity.offset,
- );
- }
-}
diff --git a/render-wasm/src/wasm.rs b/render-wasm/src/wasm.rs
index 8dedf0a97f..3612a79984 100644
--- a/render-wasm/src/wasm.rs
+++ b/render-wasm/src/wasm.rs
@@ -9,3 +9,4 @@ pub mod shapes;
pub mod strokes;
pub mod svg_attrs;
pub mod text;
+pub mod text_editor;
diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs
new file mode 100644
index 0000000000..37758f5bb1
--- /dev/null
+++ b/render-wasm/src/wasm/text_editor.rs
@@ -0,0 +1,1341 @@
+use macros::ToJs;
+
+use crate::math::{Matrix, Point, Rect};
+use crate::mem;
+use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign};
+use crate::state::{TextCursor, TextSelection};
+use crate::utils::uuid_from_u32_quartet;
+use crate::utils::uuid_to_u32_quartet;
+use crate::{with_state, with_state_mut, STATE};
+
+#[derive(PartialEq, ToJs)]
+#[repr(u8)]
+#[allow(dead_code)]
+pub enum CursorDirection {
+ Backward = 0,
+ Forward = 1,
+ LineBefore = 2,
+ LineAfter = 3,
+ LineStart = 4,
+ LineEnd = 5,
+}
+
+// ============================================================================
+// STATE MANAGEMENT
+// ============================================================================
+
+#[no_mangle]
+pub extern "C" fn text_editor_start(a: u32, b: u32, c: u32, d: u32) -> bool {
+ with_state_mut!(state, {
+ let shape_id = uuid_from_u32_quartet(a, b, c, d);
+
+ let Some(shape) = state.shapes.get(&shape_id) else {
+ return false;
+ };
+
+ if !matches!(shape.shape_type, Type::Text(_)) {
+ return false;
+ }
+
+ state.text_editor_state.start(shape_id);
+ true
+ })
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_stop() {
+ with_state_mut!(state, {
+ state.text_editor_state.stop();
+ });
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_is_active() -> bool {
+ with_state!(state, { state.text_editor_state.is_active })
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) {
+ with_state!(state, {
+ if let Some(shape_id) = state.text_editor_state.active_shape_id {
+ let (a, b, c, d) = uuid_to_u32_quartet(&shape_id);
+ unsafe {
+ *buffer_ptr = a;
+ *buffer_ptr.add(1) = b;
+ *buffer_ptr.add(2) = c;
+ *buffer_ptr.add(3) = d;
+ }
+ }
+ })
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_select_all() {
+ with_state_mut!(state, {
+ if !state.text_editor_state.is_active {
+ return;
+ }
+
+ let Some(shape_id) = state.text_editor_state.active_shape_id else {
+ return;
+ };
+
+ let Some(shape) = state.shapes.get(&shape_id) else {
+ return;
+ };
+
+ let Type::Text(text_content) = &shape.shape_type else {
+ return;
+ };
+
+ let paragraphs = text_content.paragraphs();
+ if paragraphs.is_empty() {
+ return;
+ }
+
+ let last_para_idx = paragraphs.len() - 1;
+ let last_para = ¶graphs[last_para_idx];
+ let total_chars: usize = last_para
+ .children()
+ .iter()
+ .map(|span| span.text.chars().count())
+ .sum();
+
+ use crate::state::TextCursor;
+ state.text_editor_state.selection.anchor = TextCursor::new(0, 0);
+ state.text_editor_state.selection.focus = TextCursor::new(last_para_idx, total_chars);
+ state.text_editor_state.reset_blink();
+ state
+ .text_editor_state
+ .push_event(crate::state::EditorEvent::SelectionChanged);
+ });
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_poll_event() -> u8 {
+ with_state_mut!(state, { state.text_editor_state.poll_event() as u8 })
+}
+
+// ============================================================================
+// SELECTION MANAGEMENT
+// ============================================================================
+
+#[no_mangle]
+pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
+ with_state_mut!(state, {
+ if !state.text_editor_state.is_active {
+ return;
+ }
+
+ let Some(shape_id) = state.text_editor_state.active_shape_id else {
+ return;
+ };
+
+ let (shape_matrix, view_matrix, selrect, vertical_align) = {
+ let Some(shape) = state.shapes.get(&shape_id) else {
+ return;
+ };
+ (
+ shape.get_concatenated_matrix(&state.shapes),
+ state.render_state.viewbox.get_matrix(),
+ shape.selrect(),
+ shape.vertical_align(),
+ )
+ };
+
+ let Some(inv_view_matrix) = view_matrix.invert() else {
+ return;
+ };
+
+ let Some(inv_shape_matrix) = shape_matrix.invert() else {
+ return;
+ };
+
+ let mut matrix = Matrix::new_identity();
+ matrix.post_concat(&inv_view_matrix);
+ matrix.post_concat(&inv_shape_matrix);
+
+ let mapped_point = matrix.map_point(Point::new(x, y));
+
+ let Some(shape) = state.shapes.get_mut(&shape_id) else {
+ return;
+ };
+
+ let Type::Text(text_content) = &mut shape.shape_type else {
+ return;
+ };
+
+ if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() {
+ let bounds = text_content.bounds;
+ text_content.update_layout(bounds);
+ }
+
+ // Calculate vertical alignment offset (same as in render/text_editor.rs)
+ let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
+ let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
+ let vertical_offset = match vertical_align {
+ crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0,
+ crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height,
+ _ => 0.0,
+ };
+
+ // Adjust point: subtract selrect offset and vertical alignment
+ // The text layout expects coordinates where (0, 0) is the top-left of the text content
+ let adjusted_point = Point::new(
+ mapped_point.x - selrect.x(),
+ mapped_point.y - selrect.y() - vertical_offset,
+ );
+
+ if let Some(position) = text_content.get_caret_position_at(&adjusted_point) {
+ state.text_editor_state.set_caret_from_position(position);
+ }
+ });
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_extend_selection_to_point(x: f32, y: f32) {
+ with_state_mut!(state, {
+ if !state.text_editor_state.is_active {
+ return;
+ }
+
+ let Some(shape_id) = state.text_editor_state.active_shape_id else {
+ return;
+ };
+
+ let (shape_matrix, view_matrix, selrect, vertical_align) = {
+ let Some(shape) = state.shapes.get(&shape_id) else {
+ return;
+ };
+ (
+ shape.get_concatenated_matrix(&state.shapes),
+ state.render_state.viewbox.get_matrix(),
+ shape.selrect(),
+ shape.vertical_align(),
+ )
+ };
+
+ let Some(inv_view_matrix) = view_matrix.invert() else {
+ return;
+ };
+
+ let Some(inv_shape_matrix) = shape_matrix.invert() else {
+ return;
+ };
+
+ let mut matrix = Matrix::new_identity();
+ matrix.post_concat(&inv_view_matrix);
+ matrix.post_concat(&inv_shape_matrix);
+
+ let mapped_point = matrix.map_point(Point::new(x, y));
+
+ let Some(shape) = state.shapes.get_mut(&shape_id) else {
+ return;
+ };
+
+ let Type::Text(text_content) = &mut shape.shape_type else {
+ return;
+ };
+
+ if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() {
+ let bounds = text_content.bounds;
+ text_content.update_layout(bounds);
+ }
+
+ // Calculate vertical alignment offset (same as in render/text_editor.rs)
+ let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
+ let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
+ let vertical_offset = match vertical_align {
+ crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0,
+ crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height,
+ _ => 0.0,
+ };
+
+ // Adjust point: subtract selrect offset and vertical alignment
+ let adjusted_point = Point::new(
+ mapped_point.x - selrect.x(),
+ mapped_point.y - selrect.y() - vertical_offset,
+ );
+
+ if let Some(position) = text_content.get_caret_position_at(&adjusted_point) {
+ state
+ .text_editor_state
+ .extend_selection_from_position(position);
+ }
+ });
+}
+
+// ============================================================================
+// TEXT OPERATIONS
+// ============================================================================
+
+#[no_mangle]
+pub extern "C" fn text_editor_insert_text() {
+ let bytes = crate::mem::bytes();
+ let text = match String::from_utf8(bytes) {
+ Ok(s) => s,
+ Err(_) => return,
+ };
+
+ with_state_mut!(state, {
+ if !state.text_editor_state.is_active {
+ return;
+ }
+
+ let Some(shape_id) = state.text_editor_state.active_shape_id else {
+ return;
+ };
+
+ let Some(shape) = state.shapes.get_mut(&shape_id) else {
+ return;
+ };
+
+ let Type::Text(text_content) = &mut shape.shape_type else {
+ return;
+ };
+
+ let selection = state.text_editor_state.selection;
+
+ if selection.is_selection() {
+ 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 let Some(new_offset) = insert_text_at_cursor(text_content, &cursor, &text) {
+ let new_cursor = TextCursor::new(cursor.paragraph, new_offset);
+ 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::EditorEvent::ContentChanged);
+ state
+ .text_editor_state
+ .push_event(crate::state::EditorEvent::NeedsLayout);
+
+ state.render_state.mark_touched(shape_id);
+ });
+
+ crate::mem::free_bytes();
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_delete_backward() {
+ with_state_mut!(state, {
+ if !state.text_editor_state.is_active {
+ return;
+ }
+
+ let Some(shape_id) = state.text_editor_state.active_shape_id else {
+ return;
+ };
+
+ let Some(shape) = state.shapes.get_mut(&shape_id) else {
+ return;
+ };
+
+ let Type::Text(text_content) = &mut shape.shape_type else {
+ return;
+ };
+
+ let selection = state.text_editor_state.selection;
+
+ if selection.is_selection() {
+ delete_selection_range(text_content, &selection);
+ let start = selection.start();
+ let clamped = clamp_cursor(start, text_content.paragraphs());
+ state.text_editor_state.selection.set_caret(clamped);
+ } else {
+ let cursor = selection.focus;
+ if let Some(new_cursor) = 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::EditorEvent::ContentChanged);
+ state
+ .text_editor_state
+ .push_event(crate::state::EditorEvent::NeedsLayout);
+
+ state.render_state.mark_touched(shape_id);
+ });
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_delete_forward() {
+ with_state_mut!(state, {
+ if !state.text_editor_state.is_active {
+ return;
+ }
+
+ let Some(shape_id) = state.text_editor_state.active_shape_id else {
+ return;
+ };
+
+ let Some(shape) = state.shapes.get_mut(&shape_id) else {
+ return;
+ };
+
+ let Type::Text(text_content) = &mut shape.shape_type else {
+ return;
+ };
+
+ let selection = state.text_editor_state.selection;
+
+ if selection.is_selection() {
+ delete_selection_range(text_content, &selection);
+ let start = selection.start();
+ let clamped = clamp_cursor(start, text_content.paragraphs());
+ state.text_editor_state.selection.set_caret(clamped);
+ } else {
+ let cursor = selection.focus;
+ delete_char_after(text_content, &cursor);
+ let clamped = 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::EditorEvent::ContentChanged);
+ state
+ .text_editor_state
+ .push_event(crate::state::EditorEvent::NeedsLayout);
+
+ state.render_state.mark_touched(shape_id);
+ });
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_insert_paragraph() {
+ with_state_mut!(state, {
+ if !state.text_editor_state.is_active {
+ return;
+ }
+
+ let Some(shape_id) = state.text_editor_state.active_shape_id else {
+ return;
+ };
+
+ let Some(shape) = state.shapes.get_mut(&shape_id) else {
+ return;
+ };
+
+ let Type::Text(text_content) = &mut shape.shape_type else {
+ return;
+ };
+
+ let selection = state.text_editor_state.selection;
+
+ if selection.is_selection() {
+ 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 split_paragraph_at_cursor(text_content, &cursor) {
+ let new_cursor = TextCursor::new(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::EditorEvent::ContentChanged);
+ state
+ .text_editor_state
+ .push_event(crate::state::EditorEvent::NeedsLayout);
+
+ state.render_state.mark_touched(shape_id);
+ });
+}
+
+// ============================================================================
+// NAVIGATION
+// ============================================================================
+
+#[no_mangle]
+pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_selection: bool) {
+ with_state_mut!(state, {
+ if !state.text_editor_state.is_active {
+ return;
+ }
+
+ let Some(shape_id) = state.text_editor_state.active_shape_id else {
+ return;
+ };
+
+ let Some(shape) = state.shapes.get(&shape_id) else {
+ return;
+ };
+
+ let Type::Text(text_content) = &shape.shape_type else {
+ return;
+ };
+
+ let paragraphs = text_content.paragraphs();
+ if paragraphs.is_empty() {
+ return;
+ }
+
+ let current = state.text_editor_state.selection.focus;
+
+ let new_cursor = match direction {
+ CursorDirection::Backward => move_cursor_backward(¤t, paragraphs),
+ CursorDirection::Forward => move_cursor_forward(¤t, paragraphs),
+ CursorDirection::LineBefore => {
+ move_cursor_up(¤t, paragraphs, text_content, shape)
+ }
+ CursorDirection::LineAfter => {
+ move_cursor_down(¤t, paragraphs, text_content, shape)
+ }
+ CursorDirection::LineStart => move_cursor_line_start(¤t, paragraphs),
+ CursorDirection::LineEnd => move_cursor_line_end(¤t, 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::EditorEvent::SelectionChanged);
+ });
+}
+
+// ============================================================================
+// RENDERING & EXPORT
+// ============================================================================
+
+#[no_mangle]
+pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
+ with_state_mut!(state, {
+ if !state.text_editor_state.is_active || !state.text_editor_state.cursor_visible {
+ 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 cursor = &state.text_editor_state.selection.focus;
+
+ if let Some(rect) = get_cursor_rect(text_content, cursor, shape) {
+ let mut bytes = vec![0u8; 16];
+ bytes[0..4].copy_from_slice(&rect.left().to_le_bytes());
+ bytes[4..8].copy_from_slice(&rect.top().to_le_bytes());
+ bytes[8..12].copy_from_slice(&rect.width().to_le_bytes());
+ bytes[12..16].copy_from_slice(&rect.height().to_le_bytes());
+ return mem::write_bytes(bytes);
+ }
+
+ std::ptr::null_mut()
+ })
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 {
+ with_state_mut!(state, {
+ if !state.text_editor_state.is_active {
+ return std::ptr::null_mut();
+ }
+
+ if state.text_editor_state.selection.is_collapsed() {
+ 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 selection = &state.text_editor_state.selection;
+ let rects = get_selection_rects(text_content, selection, shape);
+
+ if rects.is_empty() {
+ return std::ptr::null_mut();
+ }
+
+ let mut bytes = Vec::with_capacity(4 + rects.len() * 16);
+ bytes.extend_from_slice(&(rects.len() as u32).to_le_bytes());
+ for rect in rects {
+ bytes.extend_from_slice(&rect.left().to_le_bytes());
+ bytes.extend_from_slice(&rect.top().to_le_bytes());
+ bytes.extend_from_slice(&rect.width().to_le_bytes());
+ bytes.extend_from_slice(&rect.height().to_le_bytes());
+ }
+ mem::write_bytes(bytes)
+ })
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_update_blink(timestamp_ms: f64) {
+ with_state_mut!(state, {
+ state.text_editor_state.update_blink(timestamp_ms);
+ });
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_render_overlay() {
+ with_state_mut!(state, {
+ if !state.text_editor_state.is_active {
+ return;
+ }
+
+ let Some(shape_id) = state.text_editor_state.active_shape_id else {
+ return;
+ };
+
+ if let Some(shape) = state.shapes.get(&shape_id) {
+ if let Type::Text(text_content) = &shape.shape_type {
+ if text_content.needs_update_layout() {
+ let selrect = shape.selrect();
+ if let Some(shape) = state.shapes.get_mut(&shape_id) {
+ if let Type::Text(text_content) = &mut shape.shape_type {
+ text_content.update_layout(selrect);
+ }
+ }
+ }
+ }
+ }
+
+ let Some(shape) = state.shapes.get(&shape_id) else {
+ return;
+ };
+
+ let transform = shape.get_concatenated_matrix(&state.shapes);
+
+ use crate::render::text_editor as te_render;
+ use crate::render::SurfaceId;
+
+ let canvas = state.render_state.surfaces.canvas(SurfaceId::Target);
+
+ canvas.save();
+ let viewbox = state.render_state.viewbox;
+ let zoom = viewbox.zoom * state.render_state.options.dpr();
+ canvas.scale((zoom, zoom));
+ canvas.translate((-viewbox.area.left, -viewbox.area.top));
+
+ te_render::render_overlay(canvas, &state.text_editor_state, shape, &transform);
+
+ canvas.restore();
+ state.render_state.flush_and_submit();
+ });
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_export_content() -> *mut u8 {
+ with_state!(state, {
+ if !state.text_editor_state.is_active {
+ 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 mut json_parts: Vec = Vec::new();
+ for para in text_content.paragraphs() {
+ let mut span_parts: Vec = Vec::new();
+ for span in para.children() {
+ let escaped_text = span
+ .text
+ .replace('\\', "\\\\")
+ .replace('"', "\\\"")
+ .replace('\n', "\\n")
+ .replace('\r', "\\r")
+ .replace('\t', "\\t");
+ span_parts.push(format!("\"{}\"", escaped_text));
+ }
+ json_parts.push(format!("[{}]", span_parts.join(",")));
+ }
+ let json = format!("[{}]", json_parts.join(","));
+
+ let mut bytes = json.into_bytes();
+ bytes.push(0);
+ crate::mem::write_bytes(bytes)
+ })
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_export_selection() -> *mut u8 {
+ use std::ptr;
+ with_state!(state, {
+ if !state.text_editor_state.is_active {
+ return ptr::null_mut();
+ }
+ let Some(shape_id) = state.text_editor_state.active_shape_id else {
+ return ptr::null_mut();
+ };
+ let Some(shape) = state.shapes.get(&shape_id) else {
+ return ptr::null_mut();
+ };
+ let Type::Text(text_content) = &shape.shape_type else {
+ return ptr::null_mut();
+ };
+ let selection = &state.text_editor_state.selection;
+ let start = selection.start();
+ let end = selection.end();
+ let paragraphs = text_content.paragraphs();
+ let mut result = String::new();
+ let end_paragraph = end.paragraph.min(paragraphs.len().saturating_sub(1)) + 1;
+ for (para_idx, _) in paragraphs
+ .iter()
+ .enumerate()
+ .take(end_paragraph)
+ .skip(start.paragraph)
+ {
+ let para = ¶graphs[para_idx];
+ let mut para_text = String::new();
+ let para_char_count: usize = para
+ .children()
+ .iter()
+ .map(|span| span.text.chars().count())
+ .sum();
+ let range_start = if para_idx == start.paragraph {
+ start.char_offset
+ } else {
+ 0
+ };
+ let range_end = if para_idx == end.paragraph {
+ end.char_offset
+ } else {
+ para_char_count
+ };
+ if range_start < range_end {
+ let mut char_pos = 0;
+ for span in para.children() {
+ let span_len = span.text.chars().count();
+ let span_start = char_pos;
+ let span_end = char_pos + span_len;
+ let sel_start = range_start.max(span_start);
+ let sel_end = range_end.min(span_end);
+ if sel_start < sel_end {
+ let rel_start = sel_start - span_start;
+ let rel_end = sel_end - span_start;
+ let text: String = span
+ .text
+ .chars()
+ .skip(rel_start)
+ .take(rel_end - rel_start)
+ .collect();
+ para_text.push_str(&text);
+ }
+ char_pos += span_len;
+ }
+ }
+ if !para_text.is_empty() {
+ if !result.is_empty() {
+ result.push('\n');
+ }
+ result.push_str(¶_text);
+ }
+ }
+ let mut bytes = result.into_bytes();
+ bytes.push(0);
+ crate::mem::write_bytes(bytes)
+ })
+}
+
+#[no_mangle]
+pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> u32 {
+ with_state!(state, {
+ if !state.text_editor_state.is_active {
+ return 0;
+ }
+ let sel = &state.text_editor_state.selection;
+ unsafe {
+ *buffer_ptr = sel.anchor.paragraph as u32;
+ *buffer_ptr.add(1) = sel.anchor.char_offset as u32;
+ *buffer_ptr.add(2) = sel.focus.paragraph as u32;
+ *buffer_ptr.add(3) = sel.focus.char_offset as u32;
+ }
+ 1
+ })
+}
+
+// ============================================================================
+// HELPERS: Cursor & Selection
+// ============================================================================
+
+fn get_cursor_rect(text_content: &TextContent, cursor: &TextCursor, shape: &Shape) -> Option {
+ let paragraphs = text_content.paragraphs();
+ if cursor.paragraph >= paragraphs.len() {
+ return None;
+ }
+
+ let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
+
+ let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
+ let valign_offset = match shape.vertical_align() {
+ VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0,
+ VerticalAlign::Bottom => shape.selrect().height() - total_height,
+ _ => 0.0,
+ };
+
+ let mut y_offset = valign_offset;
+ for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
+ if idx == cursor.paragraph {
+ let char_pos = cursor.char_offset;
+
+ use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
+ let rects = laid_out_para.get_rects_for_range(
+ char_pos..char_pos,
+ RectHeightStyle::Tight,
+ RectWidthStyle::Tight,
+ );
+
+ let (x, height) = if !rects.is_empty() {
+ (rects[0].rect.left(), rects[0].rect.height())
+ } else {
+ let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
+ let height = laid_out_para.height();
+ (pos.position as f32, height)
+ };
+
+ let cursor_width = 2.0;
+ let selrect = shape.selrect();
+ let base_x = selrect.x();
+ let base_y = selrect.y() + y_offset;
+
+ return Some(Rect::from_xywh(base_x + x, base_y, cursor_width, height));
+ }
+ y_offset += laid_out_para.height();
+ }
+
+ None
+}
+
+/// Get selection rectangles for a given selection.
+fn get_selection_rects(
+ text_content: &TextContent,
+ selection: &TextSelection,
+ shape: &Shape,
+) -> Vec {
+ let mut rects = Vec::new();
+
+ let start = selection.start();
+ let end = selection.end();
+
+ let paragraphs = text_content.paragraphs();
+ let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
+
+ let selrect = shape.selrect();
+
+ let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
+ let valign_offset = match shape.vertical_align() {
+ VerticalAlign::Center => (selrect.height() - total_height) / 2.0,
+ VerticalAlign::Bottom => selrect.height() - total_height,
+ _ => 0.0,
+ };
+
+ let mut y_offset = valign_offset;
+
+ for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
+ let para_height = laid_out_para.height();
+
+ if para_idx < start.paragraph || para_idx > end.paragraph {
+ y_offset += para_height;
+ continue;
+ }
+
+ if para_idx >= paragraphs.len() {
+ y_offset += para_height;
+ continue;
+ }
+
+ let para = ¶graphs[para_idx];
+ let para_char_count: usize = para
+ .children()
+ .iter()
+ .map(|span| span.text.chars().count())
+ .sum();
+ let range_start = if para_idx == start.paragraph {
+ start.char_offset
+ } else {
+ 0
+ };
+
+ let range_end = if para_idx == end.paragraph {
+ end.char_offset
+ } else {
+ para_char_count
+ };
+
+ if range_start < range_end {
+ use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
+ let text_boxes = laid_out_para.get_rects_for_range(
+ range_start..range_end,
+ RectHeightStyle::Tight,
+ RectWidthStyle::Tight,
+ );
+
+ for text_box in text_boxes {
+ let r = text_box.rect;
+ rects.push(Rect::from_xywh(
+ selrect.x() + r.left(),
+ selrect.y() + y_offset + r.top(),
+ r.width(),
+ r.height(),
+ ));
+ }
+ }
+
+ y_offset += para_height;
+ }
+
+ rects
+}
+
+/// Get total character count in a paragraph.
+fn paragraph_char_count(para: &Paragraph) -> usize {
+ para.children()
+ .iter()
+ .map(|span| span.text.chars().count())
+ .sum()
+}
+
+/// Clamp a cursor position to valid bounds within the text content.
+fn clamp_cursor(cursor: TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
+ if paragraphs.is_empty() {
+ return TextCursor::new(0, 0);
+ }
+
+ let para_idx = cursor.paragraph.min(paragraphs.len() - 1);
+ let para_len = paragraph_char_count(¶graphs[para_idx]);
+ let char_offset = cursor.char_offset.min(para_len);
+
+ TextCursor::new(para_idx, char_offset)
+}
+
+/// Move cursor left by one character.
+fn move_cursor_backward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
+ if cursor.char_offset > 0 {
+ TextCursor::new(cursor.paragraph, cursor.char_offset - 1)
+ } else if cursor.paragraph > 0 {
+ let prev_para = cursor.paragraph - 1;
+ let char_count = paragraph_char_count(¶graphs[prev_para]);
+ TextCursor::new(prev_para, char_count)
+ } else {
+ *cursor
+ }
+}
+
+/// Move cursor right by one character.
+fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
+ let para = ¶graphs[cursor.paragraph];
+ let char_count = paragraph_char_count(para);
+
+ if cursor.char_offset < char_count {
+ TextCursor::new(cursor.paragraph, cursor.char_offset + 1)
+ } else if cursor.paragraph < paragraphs.len() - 1 {
+ TextCursor::new(cursor.paragraph + 1, 0)
+ } else {
+ *cursor
+ }
+}
+
+/// Move cursor up by one line.
+fn move_cursor_up(
+ cursor: &TextCursor,
+ paragraphs: &[Paragraph],
+ _text_content: &TextContent,
+ _shape: &Shape,
+) -> TextCursor {
+ // TODO: Implement proper line-based navigation using line metrics
+ if cursor.paragraph > 0 {
+ let prev_para = cursor.paragraph - 1;
+ let char_count = paragraph_char_count(¶graphs[prev_para]);
+ let new_offset = cursor.char_offset.min(char_count);
+ TextCursor::new(prev_para, new_offset)
+ } else {
+ TextCursor::new(cursor.paragraph, 0)
+ }
+}
+
+/// Move cursor down by one line.
+fn move_cursor_down(
+ cursor: &TextCursor,
+ paragraphs: &[Paragraph],
+ _text_content: &TextContent,
+ _shape: &Shape,
+) -> TextCursor {
+ // TODO: Implement proper line-based navigation using line metrics
+ if cursor.paragraph < paragraphs.len() - 1 {
+ let next_para = cursor.paragraph + 1;
+ let char_count = paragraph_char_count(¶graphs[next_para]);
+ let new_offset = cursor.char_offset.min(char_count);
+ TextCursor::new(next_para, new_offset)
+ } else {
+ let char_count = paragraph_char_count(¶graphs[cursor.paragraph]);
+ TextCursor::new(cursor.paragraph, char_count)
+ }
+}
+
+/// Move cursor to start of current line.
+fn move_cursor_line_start(cursor: &TextCursor, _paragraphs: &[Paragraph]) -> TextCursor {
+ // TODO: Implement proper line-start using line metrics
+ TextCursor::new(cursor.paragraph, 0)
+}
+
+/// Move cursor to end of current line.
+fn move_cursor_line_end(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor {
+ // TODO: Implement proper line-end using line metrics
+ let char_count = paragraph_char_count(¶graphs[cursor.paragraph]);
+ TextCursor::new(cursor.paragraph, char_count)
+}
+
+// ============================================================================
+// HELPERS: Text Modification
+// ============================================================================
+
+fn find_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() {
+ let span_len = span.text.chars().count();
+ if char_offset <= accumulated + span_len {
+ return Some((span_idx, char_offset - accumulated));
+ }
+ accumulated += span_len;
+ }
+ if !children.is_empty() {
+ let last_idx = children.len() - 1;
+ let last_len = children[last_idx].text.chars().count();
+ return Some((last_idx, last_len));
+ }
+ None
+}
+
+/// Insert text at a cursor position. Returns the new character offset after insertion.
+fn insert_text_at_cursor(
+ text_content: &mut TextContent,
+ cursor: &TextCursor,
+ text: &str,
+) -> Option {
+ let paragraphs = text_content.paragraphs_mut();
+ if cursor.paragraph >= paragraphs.len() {
+ return None;
+ }
+
+ let para = &mut paragraphs[cursor.paragraph];
+
+ let children = para.children_mut();
+ if children.is_empty() {
+ return None;
+ }
+
+ if children.len() == 1 && children[0].text.is_empty() {
+ children[0].set_text(text.to_string());
+ return Some(text.chars().count());
+ }
+
+ let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.char_offset)?;
+
+ let children = para.children_mut();
+ let span = &mut children[span_idx];
+ let mut new_text = span.text.clone();
+
+ let byte_offset = new_text
+ .char_indices()
+ .nth(offset_in_span)
+ .map(|(i, _)| i)
+ .unwrap_or(new_text.len());
+
+ new_text.insert_str(byte_offset, text);
+ span.set_text(new_text);
+
+ Some(cursor.char_offset + text.chars().count())
+}
+
+/// Delete a range of text specified by a selection.
+fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) {
+ let start = selection.start();
+ let end = selection.end();
+
+ let paragraphs = text_content.paragraphs_mut();
+ if start.paragraph >= paragraphs.len() {
+ return;
+ }
+
+ if start.paragraph == end.paragraph {
+ delete_range_in_paragraph(
+ &mut paragraphs[start.paragraph],
+ start.char_offset,
+ end.char_offset,
+ );
+ } else {
+ let start_para_len = paragraph_char_count(¶graphs[start.paragraph]);
+ delete_range_in_paragraph(
+ &mut paragraphs[start.paragraph],
+ start.char_offset,
+ start_para_len,
+ );
+
+ delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.char_offset);
+
+ if end.paragraph < paragraphs.len() {
+ let end_para_children: Vec<_> =
+ paragraphs[end.paragraph].children_mut().drain(..).collect();
+ paragraphs[start.paragraph]
+ .children_mut()
+ .extend(end_para_children);
+ }
+
+ if end.paragraph < paragraphs.len() {
+ paragraphs.drain((start.paragraph + 1)..=end.paragraph);
+ }
+
+ let children = paragraphs[start.paragraph].children_mut();
+ let has_content = children.iter().any(|span| !span.text.is_empty());
+ if has_content {
+ children.retain(|span| !span.text.is_empty());
+ } else if children.len() > 1 {
+ children.truncate(1);
+ }
+ }
+}
+
+/// Delete a range of characters within a single paragraph.
+fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offset: usize) {
+ if start_offset >= end_offset {
+ return;
+ }
+
+ let mut accumulated = 0;
+ let mut delete_start_span = None;
+ let mut delete_end_span = None;
+
+ for (idx, span) in para.children().iter().enumerate() {
+ let span_len = span.text.chars().count();
+ let span_end = accumulated + span_len;
+
+ if delete_start_span.is_none() && start_offset < span_end {
+ delete_start_span = Some((idx, start_offset - accumulated));
+ }
+ if end_offset <= span_end {
+ delete_end_span = Some((idx, end_offset - accumulated));
+ break;
+ }
+ accumulated += span_len;
+ }
+
+ let Some((start_span_idx, start_in_span)) = delete_start_span else {
+ return;
+ };
+ let Some((end_span_idx, end_in_span)) = delete_end_span else {
+ return;
+ };
+
+ let children = para.children_mut();
+
+ if start_span_idx == end_span_idx {
+ let span = &mut children[start_span_idx];
+ let text = span.text.clone();
+ let chars: Vec = text.chars().collect();
+
+ let start_clamped = start_in_span.min(chars.len());
+ let end_clamped = end_in_span.min(chars.len());
+
+ let new_text: String = chars[..start_clamped]
+ .iter()
+ .chain(chars[end_clamped..].iter())
+ .collect();
+ span.set_text(new_text);
+ } else {
+ let start_span = &mut children[start_span_idx];
+ let text = start_span.text.clone();
+ let start_char_count = text.chars().count();
+ let start_clamped = start_in_span.min(start_char_count);
+ let new_text: String = text.chars().take(start_clamped).collect();
+ start_span.set_text(new_text);
+
+ let end_span = &mut children[end_span_idx];
+ let text = end_span.text.clone();
+ let end_char_count = text.chars().count();
+ let end_clamped = end_in_span.min(end_char_count);
+ let new_text: String = text.chars().skip(end_clamped).collect();
+ end_span.set_text(new_text);
+
+ if end_span_idx > start_span_idx + 1 {
+ children.drain((start_span_idx + 1)..end_span_idx);
+ }
+ }
+
+ let has_content = children.iter().any(|span| !span.text.is_empty());
+ if has_content {
+ children.retain(|span| !span.text.is_empty());
+ } else if !children.is_empty() {
+ children.truncate(1);
+ }
+}
+
+/// Delete the character before the cursor. Returns the new cursor position.
+fn delete_char_before(text_content: &mut TextContent, cursor: &TextCursor) -> Option {
+ if cursor.char_offset > 0 {
+ let paragraphs = text_content.paragraphs_mut();
+ let para = &mut paragraphs[cursor.paragraph];
+ let delete_pos = cursor.char_offset - 1;
+ delete_range_in_paragraph(para, delete_pos, cursor.char_offset);
+ Some(TextCursor::new(cursor.paragraph, delete_pos))
+ } else if cursor.paragraph > 0 {
+ let prev_para_idx = cursor.paragraph - 1;
+ let paragraphs = text_content.paragraphs_mut();
+ let prev_para_len = paragraph_char_count(¶graphs[prev_para_idx]);
+
+ let current_children: Vec<_> = paragraphs[cursor.paragraph]
+ .children_mut()
+ .drain(..)
+ .collect();
+ paragraphs[prev_para_idx]
+ .children_mut()
+ .extend(current_children);
+
+ paragraphs.remove(cursor.paragraph);
+
+ Some(TextCursor::new(prev_para_idx, prev_para_len))
+ } else {
+ None
+ }
+}
+
+/// Delete the character after the cursor.
+fn delete_char_after(text_content: &mut TextContent, cursor: &TextCursor) {
+ let paragraphs = text_content.paragraphs_mut();
+ if cursor.paragraph >= paragraphs.len() {
+ return;
+ }
+
+ let para_len = paragraph_char_count(¶graphs[cursor.paragraph]);
+
+ if cursor.char_offset < para_len {
+ let para = &mut paragraphs[cursor.paragraph];
+ delete_range_in_paragraph(para, cursor.char_offset, cursor.char_offset + 1);
+ } else if cursor.paragraph < paragraphs.len() - 1 {
+ let next_para_idx = cursor.paragraph + 1;
+ let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
+ paragraphs[cursor.paragraph]
+ .children_mut()
+ .extend(next_children);
+
+ paragraphs.remove(next_para_idx);
+ }
+}
+
+/// Split a paragraph at the cursor position. Returns true if split was successful.
+fn split_paragraph_at_cursor(text_content: &mut TextContent, cursor: &TextCursor) -> bool {
+ let paragraphs = text_content.paragraphs_mut();
+ if cursor.paragraph >= paragraphs.len() {
+ return false;
+ }
+
+ let para = ¶graphs[cursor.paragraph];
+
+ let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.char_offset) else {
+ return false;
+ };
+
+ let mut new_para_children = Vec::new();
+ let children = para.children();
+
+ let current_span = &children[span_idx];
+ let span_text = current_span.text.clone();
+ let chars: Vec = span_text.chars().collect();
+
+ if offset_in_span < chars.len() {
+ let after_text: String = chars[offset_in_span..].iter().collect();
+ let mut new_span = current_span.clone();
+ new_span.set_text(after_text);
+ new_para_children.push(new_span);
+ }
+
+ for child in children.iter().skip(span_idx + 1) {
+ new_para_children.push(child.clone());
+ }
+
+ if new_para_children.is_empty() {
+ let mut empty_span = current_span.clone();
+ empty_span.set_text(String::new());
+ new_para_children.push(empty_span);
+ }
+
+ let text_align = para.text_align();
+ let text_direction = para.text_direction();
+ let text_decoration = para.text_decoration();
+ let text_transform = para.text_transform();
+ let line_height = para.line_height();
+ let letter_spacing = para.letter_spacing();
+
+ let para = &mut paragraphs[cursor.paragraph];
+ let children = para.children_mut();
+
+ children.truncate(span_idx + 1);
+
+ if !children.is_empty() {
+ let span = &mut children[span_idx];
+ let text = span.text.clone();
+ let new_text: String = text.chars().take(offset_in_span).collect();
+ span.set_text(new_text);
+ }
+
+ let new_para = crate::shapes::Paragraph::new(
+ text_align,
+ text_direction,
+ text_decoration,
+ text_transform,
+ line_height,
+ letter_spacing,
+ new_para_children,
+ );
+
+ paragraphs.insert(cursor.paragraph + 1, new_para);
+
+ true
+}