From 0b41a910bfeb2f6192969e6c44fecab683839aa1 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Wed, 4 Mar 2026 10:44:00 +0100 Subject: [PATCH] :tada: Add word boundary selection --- .../ui/workspace/shapes/text/v3_editor.cljs | 16 +++- frontend/src/app/render_wasm/api.cljs | 1 + frontend/src/app/render_wasm/text_editor.cljs | 15 ++-- render-wasm/src/state/text_editor.rs | 74 +++++++++++++++++++ render-wasm/src/wasm/text_editor.rs | 28 +++++++ 5 files changed, 125 insertions(+), 9 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 d1d3cd477b..dd95025896 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 @@ -220,28 +220,35 @@ (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt))))) + (wasm.api/text-editor-pointer-down off-pt)))) on-pointer-move (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt))))) + (wasm.api/text-editor-pointer-move off-pt)))) on-pointer-up (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt))))) + (wasm.api/text-editor-pointer-up off-pt)))) on-click (mf/use-fn (fn [^js event] (let [native-event (dom/event->native-event event) off-pt (dom/get-offset-position native-event)] - (wasm.api/text-editor-set-cursor-from-offset (.-x off-pt) (.-y off-pt))))) + (wasm.api/text-editor-set-cursor-from-offset off-pt)))) + + on-double-click + (mf/use-fn + (fn [^js event] + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event)] + (wasm.api/text-editor-select-word-boundary off-pt)))) on-focus (mf/use-fn @@ -288,6 +295,7 @@ [:foreignObject {:x x :y y :width width :height height} [:div {:on-click on-click + :on-double-click on-double-click :on-pointer-down on-pointer-down :on-pointer-move on-pointer-move :on-pointer-up on-pointer-up diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 4e5513c2b9..8f037e2f9d 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -93,6 +93,7 @@ (def text-editor-pointer-up text-editor/text-editor-pointer-up) (def text-editor-is-active? text-editor/text-editor-is-active?) (def text-editor-select-all text-editor/text-editor-select-all) +(def text-editor-select-word-boundary text-editor/text-editor-select-word-boundary) (def text-editor-sync-content text-editor/text-editor-sync-content) (def dpr diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 4277062278..94ad28b690 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -25,28 +25,28 @@ (defn text-editor-set-cursor-from-offset "Sets caret position from shape relative coordinates" - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_set_cursor_from_offset" x y))) (defn text-editor-set-cursor-from-point "Sets caret position from screen (canvas) coordinates" - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y))) (defn text-editor-pointer-down - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_pointer_down" x y))) (defn text-editor-pointer-move - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_pointer_move" x y))) (defn text-editor-pointer-up - [x y] + [{:keys [x y]}] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_pointer_up" x y))) @@ -100,6 +100,11 @@ (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_select_all"))) +(defn text-editor-select-word-boundary + [{:keys [x y]}] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_select_word_boundary" x y))) + (defn text-editor-stop [] (when wasm/context-initialized? diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 1f5e03ac96..34f2d12239 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -199,6 +199,80 @@ impl TextEditorState { true } + pub fn select_word_boundary( + &mut self, + content: &TextContent, + position: &TextPositionWithAffinity, + ) { + fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' + } + + self.is_pointer_selection_active = false; + + let paragraphs = content.paragraphs(); + if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { + return; + } + + let paragraph = ¶graphs[position.paragraph]; + let paragraph_text: String = paragraph + .children() + .iter() + .map(|span| span.text.as_str()) + .collect(); + + let chars: Vec = paragraph_text.chars().collect(); + if chars.is_empty() { + self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + 0, + )); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + return; + } + + let mut offset = position.offset.min(chars.len()); + + if offset == chars.len() { + offset = offset.saturating_sub(1); + } else if !is_word_char(chars[offset]) && offset > 0 && is_word_char(chars[offset - 1]) { + offset -= 1; + } + + if !is_word_char(chars[offset]) { + self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + position.offset.min(chars.len()), + )); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + return; + } + + let mut start = offset; + while start > 0 && is_word_char(chars[start - 1]) { + start -= 1; + } + + let mut end = offset + 1; + while end < chars.len() && is_word_char(chars[end]) { + end += 1; + } + + self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + start, + )); + self.extend_selection_from_position(&TextPositionWithAffinity::new_without_affinity( + position.paragraph, + end, + )); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + } + pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.set_caret(*position); self.push_event(TextEditorEvent::SelectionChanged); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 2728ba9f2f..385bd63a01 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -121,6 +121,34 @@ pub extern "C" fn text_editor_select_all() -> bool { }) } +#[no_mangle] +pub extern "C" fn text_editor_select_word_boundary(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 Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + + let point = Point::new(x, y); + if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { + state + .text_editor_state + .select_word_boundary(text_content, &position); + } + }) +} + #[no_mangle] pub extern "C" fn text_editor_poll_event() -> u8 { with_state_mut!(state, { state.text_editor_state.poll_event() as u8 })