From 72f5ecfe56f80aeb7b1334f181735d0b5833b92f Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Wed, 11 Mar 2026 15:42:28 +0100 Subject: [PATCH] :tada: Feat add text editor composition update --- .../ui/workspace/shapes/text/v3_editor.cljs | 20 ++- frontend/src/app/render_wasm/text_editor.cljs | 41 +++++- render-wasm/src/state/text_editor.rs | 60 ++++++++ render-wasm/src/wasm/text_editor.rs | 129 ++++++++++++++++++ 4 files changed, 243 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 576854c163..bfc2e07947 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -75,7 +75,22 @@ on-composition-start (mf/use-fn (fn [_event] - (reset! composing? true))) + (reset! composing? true) + (text-editor/text-editor-composition-start))) + + on-composition-update + (mf/use-fn + (fn [event] + (when-not composing? + (reset! composing? true)) + + (let [data (.-data event)] + (when data + (text-editor/text-editor-composition-update data) + (sync-wasm-text-editor-content!) + (wasm.api/request-render "text-composition")) + (when-let [node (mf/ref-val contenteditable-ref)] + (set! (.-textContent node) ""))))) on-composition-end (mf/use-fn @@ -83,7 +98,7 @@ (reset! composing? false) (let [data (.-data event)] (when data - (text-editor/text-editor-insert-text data) + (text-editor/text-editor-composition-end data) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-composition")) (when-let [node (mf/ref-val contenteditable-ref)] @@ -326,6 +341,7 @@ :contentEditable true :suppressContentEditableWarning true :on-composition-start on-composition-start + :on-composition-update on-composition-update :on-composition-end on-composition-end :on-key-down on-key-down :on-input on-input diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index e8e540b8f1..70c225ced2 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -66,17 +66,48 @@ (let [res (h/call wasm/internal-module "_text_editor_poll_event")] res))) -(defn text-editor-insert-text +(defn text-editor-encode-text-pre [text] - (when wasm/context-initialized? + (when (and (not (empty? text)) + 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)))) + (mem/write-buffer offset heapu8 buf)))) + +(defn text-editor-encode-text-post + [text] + (when (and (not (empty? text)) + wasm/context-initialized?) + (mem/free))) + +(defn text-editor-composition-start + [] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_composition_start"))) + +(defn text-editor-composition-update + [text] + (when wasm/context-initialized? + (text-editor-encode-text-pre text) + (h/call wasm/internal-module "_text_editor_composition_update") + (text-editor-encode-text-post text))) + +(defn text-editor-composition-end + [text] + (when wasm/context-initialized? + (text-editor-encode-text-pre text) + (h/call wasm/internal-module "_text_editor_composition_end") + (text-editor-encode-text-post text))) + +(defn text-editor-insert-text + [text] + (when wasm/context-initialized? + (text-editor-encode-text-pre text) + (h/call wasm/internal-module "_text_editor_insert_text") + (text-editor-encode-text-post text))) (defn text-editor-delete-backward ([] diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 65ffbbc19a..1c40c25053 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -103,9 +103,68 @@ pub struct TextEditorTheme { pub cursor_color: Color, } +pub struct TextComposition { + pub previous: String, + pub current: String, + pub is_composing: bool, +} + +impl TextComposition { + pub fn new() -> Self { + Self { + previous: String::new(), + current: String::new(), + is_composing: false, + } + } + + pub fn start(&mut self) -> bool { + if self.is_composing { + return false; + } + self.is_composing = true; + self.previous = String::new(); + self.current = String::new(); + true + } + + pub fn update(&mut self, text: &str) -> bool { + if !self.is_composing { + self.is_composing = true; + } + self.previous = self.current.clone(); + self.current = text.to_owned(); + true + } + + pub fn end(&mut self) -> bool { + if !self.is_composing { + return false; + } + self.is_composing = false; + true + } + + pub fn get_selection(&self, selection: &TextSelection) -> TextSelection { + if self.previous.is_empty() { + return *selection; + } + + let focus = selection.focus; + let previous_len = self.previous.chars().count(); + let anchor = TextPositionWithAffinity::new_without_affinity( + focus.paragraph, + focus.offset + previous_len, + ); + + TextSelection { anchor, focus } + } +} + pub struct TextEditorState { pub theme: TextEditorTheme, pub selection: TextSelection, + pub composition: TextComposition, pub is_active: bool, // This property indicates that we've started // selecting something with the pointer. @@ -125,6 +184,7 @@ impl TextEditorState { cursor_color: CURSOR_COLOR, }, selection: TextSelection::new(), + composition: TextComposition::new(), is_active: false, is_pointer_selection_active: false, active_shape_id: None, diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 25182c5050..eeac88ce43 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -293,6 +293,135 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { // TEXT OPERATIONS // ============================================================================ +#[no_mangle] +#[wasm_error] +pub extern "C" fn text_editor_composition_start() -> Result<()> { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return Ok(()); + } + state.text_editor_state.composition.start(); + }); + + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn text_editor_composition_end() -> Result<()> { + let bytes = crate::mem::bytes(); + let text = match String::from_utf8(bytes) { + Ok(text) => text, + Err(_) => return Ok(()), + }; + + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return Ok(()); + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return Ok(()); + }; + + let Some(shape) = state.shapes.get_mut(&shape_id) else { + return Ok(()); + }; + + let Type::Text(text_content) = &mut shape.shape_type else { + return Ok(()); + }; + + state.text_editor_state.composition.update(&text); + + let selection = state + .text_editor_state + .composition + .get_selection(&state.text_editor_state.selection); + text_helpers::delete_selection_range(text_content, &selection); + + let cursor = state.text_editor_state.selection.focus; + if let Some(new_cursor) = + text_helpers::insert_text_with_newlines(text_content, &cursor, &text) + { + state.text_editor_state.selection.set_caret(new_cursor); + } + + text_content.layout.paragraphs.clear(); + text_content.layout.paragraph_builders.clear(); + + state.text_editor_state.reset_blink(); + state + .text_editor_state + .push_event(crate::state::TextEditorEvent::ContentChanged); + state + .text_editor_state + .push_event(crate::state::TextEditorEvent::NeedsLayout); + + state.render_state.mark_touched(shape_id); + + state.text_editor_state.composition.end(); + }); + + crate::mem::free_bytes()?; + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn text_editor_composition_update() -> Result<()> { + let bytes = crate::mem::bytes(); + let text = match String::from_utf8(bytes) { + Ok(text) => text, + Err(_) => return Ok(()), + }; + + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return Ok(()); + } + + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return Ok(()); + }; + + let Some(shape) = state.shapes.get_mut(&shape_id) else { + return Ok(()); + }; + + let Type::Text(text_content) = &mut shape.shape_type else { + return Ok(()); + }; + + state.text_editor_state.composition.update(&text); + + let selection = state + .text_editor_state + .composition + .get_selection(&state.text_editor_state.selection); + text_helpers::delete_selection_range(text_content, &selection); + + let cursor = state.text_editor_state.selection.focus; + text_helpers::insert_text_with_newlines(text_content, &cursor, &text); + + text_content.layout.paragraphs.clear(); + text_content.layout.paragraph_builders.clear(); + + state.text_editor_state.reset_blink(); + state + .text_editor_state + .push_event(crate::state::TextEditorEvent::ContentChanged); + state + .text_editor_state + .push_event(crate::state::TextEditorEvent::NeedsLayout); + + state.render_state.mark_touched(shape_id); + }); + + crate::mem::free_bytes()?; + Ok(()) +} + // FIXME: Review if all the return Ok(()) should be Err instead. #[no_mangle] #[wasm_error]