From 2b525f0f480710a08f72f972b7da466722f61a17 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 26 Jan 2026 17:02:45 +0100 Subject: [PATCH 1/3] :wrench: Set up embedded editor --- common/src/app/common/features.cljc | 3 + .../src/app/main/data/workspace/texts.cljs | 16 +- .../ui/workspace/shapes/text/v2_editor.cljs | 8 +- .../main/ui/workspace/viewport/actions.cljs | 41 +- .../app/main/ui/workspace/viewport/hooks.cljs | 3 +- .../ui/workspace/viewport/viewport_ref.cljs | 8 + .../app/main/ui/workspace/viewport_wasm.cljs | 15 +- frontend/src/app/render_wasm/api.cljs | 181 ++- frontend/src/app/render_wasm/mem.cljs | 23 + frontend/src/app/render_wasm/text_editor.cljs | 300 ++++ .../app/render_wasm/text_editor_input.cljs | 240 +++ render-wasm/src/render.rs | 1 + render-wasm/src/render/text_editor.rs | 238 +++ render-wasm/src/shapes/text.rs | 150 +- render-wasm/src/state/text_editor.rs | 295 ++-- render-wasm/src/wasm.rs | 1 + render-wasm/src/wasm/text_editor.rs | 1329 +++++++++++++++++ 17 files changed, 2666 insertions(+), 186 deletions(-) create mode 100644 frontend/src/app/render_wasm/text_editor.cljs create mode 100644 frontend/src/app/render_wasm/text_editor_input.cljs create mode 100644 render-wasm/src/render/text_editor.rs create mode 100644 render-wasm/src/wasm/text_editor.rs 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..d25c713e81 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 shape-id 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/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/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..4c9bff3953 --- /dev/null +++ b/render-wasm/src/render/text_editor.rs @@ -0,0 +1,238 @@ +use crate::shapes::{Shape, TextContent, Type, VerticalAlign}; +use crate::state::{TextCursor, TextEditorState, TextSelection}; +use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; +use skia_safe::{Canvas, Color, Matrix, Paint, Rect}; + +const CURSOR_WIDTH: f32 = 1.5; +/// FIXME: Use theme color, take into account background color for contrast +const SELECTION_COLOR: Color = Color::from_argb(80, 66, 133, 244); +const CURSOR_COLOR: Color = Color::BLACK; + +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.selection, text_content, shape); + } + + if editor_state.cursor_visible { + render_cursor(canvas, &editor_state.selection.focus, text_content, shape); + } + + canvas.restore(); +} + +fn render_cursor(canvas: &Canvas, cursor: &TextCursor, text_content: &TextContent, shape: &Shape) { + let Some(rect) = calculate_cursor_rect(cursor, text_content, shape) else { + return; + }; + + let mut paint = Paint::default(); + paint.set_color(CURSOR_COLOR); + paint.set_anti_alias(true); + + canvas.draw_rect(rect, &paint); +} + +fn render_selection( + canvas: &Canvas, + selection: &TextSelection, + text_content: &TextContent, + shape: &Shape, +) { + let rects = calculate_selection_rects(selection, text_content, shape); + + if rects.is_empty() { + return; + } + + let mut paint = Paint::default(); + paint.set_color(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( + cursor: &TextCursor, + text_content: &TextContent, + 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(); + + 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::Tight, + 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::Tight, + 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::Tight, + 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, + 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::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 +} 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..e8766d49ae 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -1,9 +1,218 @@ #![allow(dead_code)] use crate::shapes::TextPositionWithAffinity; +use crate::uuid::Uuid; -/// 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, +} + +pub struct TextEditorState { + pub selection: TextSelection, + pub is_active: bool, + pub active_shape_id: Option, + pub cursor_visible: bool, + pub last_blink_time: f64, + pub x_affinity: Option, + pending_events: Vec, +} + +const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0; + +impl TextEditorState { + pub fn new() -> Self { + Self { + selection: TextSelection::new(), + is_active: false, + active_shape_id: None, + cursor_visible: true, + last_blink_time: 0.0, + x_affinity: None, + 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.x_affinity = None; + self.pending_events.clear(); + } + + pub fn stop(&mut self) { + self.is_active = false; + self.active_shape_id = None; + self.cursor_visible = false; + self.x_affinity = None; + 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.clear_x_affinity(); + 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 clear_x_affinity(&mut self) { + self.x_affinity = None; + } + + 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 +224,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..66f11d9261 --- /dev/null +++ b/render-wasm/src/wasm/text_editor.rs @@ -0,0 +1,1329 @@ +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}; + +// ============================================================================ +// 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: u8, 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 { + 0 => move_cursor_left(¤t, paragraphs), + 1 => move_cursor_right(¤t, paragraphs), + 2 => move_cursor_up(¤t, paragraphs, text_content, shape), + 3 => move_cursor_down(¤t, paragraphs, text_content, shape), + 4 => move_cursor_line_start(¤t, paragraphs), + 5 => move_cursor_line_end(¤t, paragraphs), + _ => current, + }; + + 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(); + + if direction == 0 || direction == 1 || direction == 4 || direction == 5 { + state.text_editor_state.clear_x_affinity(); + } + + 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_left(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_right(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 +} From a14c36e996043cb7d8a3847407ed57953fc5f788 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Fri, 30 Jan 2026 11:11:14 +0100 Subject: [PATCH 2/3] :books: Add embedded text editor MVP documentation --- render-wasm/docs/text_editor.md | 217 ++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 render-wasm/docs/text_editor.md 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 From 54f63c5dc5dfea39518ebd754d188a56219ef9ec Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 2 Feb 2026 09:34:37 +0100 Subject: [PATCH 3/3] :recycle: Refactor minor things --- frontend/src/app/render_wasm/api.cljs | 2 +- frontend/src/app/render_wasm/api/shared.js | 9 +++++ render-wasm/src/render/text_editor.rs | 44 +++++++++++----------- render-wasm/src/state/text_editor.rs | 30 +++++++++------ render-wasm/src/wasm/text_editor.rs | 42 +++++++++++++-------- 5 files changed, 79 insertions(+), 48 deletions(-) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index d25c713e81..46a32ef16e 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -249,7 +249,7 @@ (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 shape-id all-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)) 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/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 4c9bff3953..be37ce627d 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -1,12 +1,7 @@ use crate::shapes::{Shape, TextContent, Type, VerticalAlign}; -use crate::state::{TextCursor, TextEditorState, TextSelection}; +use crate::state::{TextEditorState, TextSelection}; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; -use skia_safe::{Canvas, Color, Matrix, Paint, Rect}; - -const CURSOR_WIDTH: f32 = 1.5; -/// FIXME: Use theme color, take into account background color for contrast -const SELECTION_COLOR: Color = Color::from_argb(80, 66, 133, 244); -const CURSOR_COLOR: Color = Color::BLACK; +use skia_safe::{BlendMode, Canvas, Matrix, Paint, Rect}; pub fn render_overlay( canvas: &Canvas, @@ -26,23 +21,28 @@ pub fn render_overlay( canvas.concat(transform); if editor_state.selection.is_selection() { - render_selection(canvas, &editor_state.selection, text_content, shape); + render_selection(canvas, editor_state, text_content, shape); } if editor_state.cursor_visible { - render_cursor(canvas, &editor_state.selection.focus, text_content, shape); + render_cursor(canvas, editor_state, text_content, shape); } canvas.restore(); } -fn render_cursor(canvas: &Canvas, cursor: &TextCursor, text_content: &TextContent, shape: &Shape) { - let Some(rect) = calculate_cursor_rect(cursor, text_content, shape) else { +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(CURSOR_COLOR); + paint.set_color(editor_state.theme.cursor_color); paint.set_anti_alias(true); canvas.draw_rect(rect, &paint); @@ -50,10 +50,11 @@ fn render_cursor(canvas: &Canvas, cursor: &TextCursor, text_content: &TextConten fn render_selection( canvas: &Canvas, - selection: &TextSelection, + 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() { @@ -61,9 +62,9 @@ fn render_selection( } let mut paint = Paint::default(); - paint.set_color(SELECTION_COLOR); + 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); } @@ -82,10 +83,11 @@ fn vertical_align_offset( } fn calculate_cursor_rect( - cursor: &TextCursor, + 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; @@ -120,7 +122,7 @@ fn calculate_cursor_rect( } else if char_pos == 0 { let rects = laid_out_para.get_rects_for_range( 0..1, - RectHeightStyle::Tight, + RectHeightStyle::Max, RectWidthStyle::Tight, ); if !rects.is_empty() { @@ -131,7 +133,7 @@ fn calculate_cursor_rect( } 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::Tight, + RectHeightStyle::Max, RectWidthStyle::Tight, ); if !rects.is_empty() { @@ -142,7 +144,7 @@ fn calculate_cursor_rect( } else { let rects = laid_out_para.get_rects_for_range( char_pos..char_pos + 1, - RectHeightStyle::Tight, + RectHeightStyle::Max, RectWidthStyle::Tight, ); if !rects.is_empty() { @@ -157,7 +159,7 @@ fn calculate_cursor_rect( return Some(Rect::from_xywh( selrect.x() + cursor_x, selrect.y() + y_offset, - CURSOR_WIDTH, + editor_state.theme.cursor_width, cursor_height, )); } @@ -216,7 +218,7 @@ fn calculate_selection_rects( use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; let text_boxes = laid_out_para.get_rects_for_range( range_start..range_end, - RectHeightStyle::Tight, + RectHeightStyle::Max, RectWidthStyle::Tight, ); diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index e8766d49ae..d7474cc92f 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -2,6 +2,7 @@ use crate::shapes::TextPositionWithAffinity; use crate::uuid::Uuid; +use skia_safe::Color; /// Cursor position within text content. /// Uses character offsets for precise positioning. @@ -105,27 +106,41 @@ pub enum EditorEvent { 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, - pub x_affinity: Option, pending_events: Vec, } -const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0; - 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, - x_affinity: None, pending_events: Vec::new(), } } @@ -136,7 +151,6 @@ impl TextEditorState { self.cursor_visible = true; self.last_blink_time = 0.0; self.selection = TextSelection::new(); - self.x_affinity = None; self.pending_events.clear(); } @@ -144,7 +158,6 @@ impl TextEditorState { self.is_active = false; self.active_shape_id = None; self.cursor_visible = false; - self.x_affinity = None; self.pending_events.clear(); } @@ -152,7 +165,6 @@ impl TextEditorState { let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize); self.selection.set_caret(cursor); self.reset_blink(); - self.clear_x_affinity(); self.push_event(EditorEvent::SelectionChanged); } @@ -186,10 +198,6 @@ impl TextEditorState { self.last_blink_time = 0.0; } - pub fn clear_x_affinity(&mut self) { - self.x_affinity = None; - } - pub fn push_event(&mut self, event: EditorEvent) { if self.pending_events.last() != Some(&event) { self.pending_events.push(event); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 66f11d9261..37758f5bb1 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1,3 +1,5 @@ +use macros::ToJs; + use crate::math::{Matrix, Point, Rect}; use crate::mem; use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign}; @@ -6,6 +8,18 @@ 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 // ============================================================================ @@ -462,7 +476,7 @@ pub extern "C" fn text_editor_insert_paragraph() { // ============================================================================ #[no_mangle] -pub extern "C" fn text_editor_move_cursor(direction: u8, extend_selection: bool) { +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; @@ -488,13 +502,16 @@ pub extern "C" fn text_editor_move_cursor(direction: u8, extend_selection: bool) let current = state.text_editor_state.selection.focus; let new_cursor = match direction { - 0 => move_cursor_left(¤t, paragraphs), - 1 => move_cursor_right(¤t, paragraphs), - 2 => move_cursor_up(¤t, paragraphs, text_content, shape), - 3 => move_cursor_down(¤t, paragraphs, text_content, shape), - 4 => move_cursor_line_start(¤t, paragraphs), - 5 => move_cursor_line_end(¤t, paragraphs), - _ => current, + 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 { @@ -504,11 +521,6 @@ pub extern "C" fn text_editor_move_cursor(direction: u8, extend_selection: bool) } state.text_editor_state.reset_blink(); - - if direction == 0 || direction == 1 || direction == 4 || direction == 5 { - state.text_editor_state.clear_x_affinity(); - } - state .text_editor_state .push_event(crate::state::EditorEvent::SelectionChanged); @@ -944,7 +956,7 @@ fn clamp_cursor(cursor: TextCursor, paragraphs: &[Paragraph]) -> TextCursor { } /// Move cursor left by one character. -fn move_cursor_left(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor { +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 { @@ -957,7 +969,7 @@ fn move_cursor_left(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor } /// Move cursor right by one character. -fn move_cursor_right(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor { +fn move_cursor_forward(cursor: &TextCursor, paragraphs: &[Paragraph]) -> TextCursor { let para = ¶graphs[cursor.paragraph]; let char_count = paragraph_char_count(para);