Merge pull request #8593 from penpot/azazeln28-feat-text-editor-composition-update

🎉 Feat add text editor composition update
This commit is contained in:
Alejandro Alonso
2026-03-19 12:27:26 +01:00
committed by GitHub
4 changed files with 243 additions and 7 deletions

View File

@@ -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

View File

@@ -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
([]

View File

@@ -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,

View File

@@ -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]