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 8a2138bb99..d4a51bac77 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 @@ -189,6 +189,13 @@ (sync-wasm-text-editor-content!) (wasm.api/request-render "text-delete-forward")) + ;; Insert + (= key "Insert") + (do + (dom/prevent-default event) + (text-editor/text-editor-toggle-overtype-mode) + (wasm.api/request-render "text-overtype-mode")) + ;; Arrow keys (= key "ArrowLeft") (do diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 897ea4aa55..8d157e33f3 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -204,6 +204,7 @@ (def text-editor-blur text-editor/text-editor-blur) (def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset) (def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point) +(def text-editor-toggle-overtype-mode text-editor/text-editor-toggle-overtype-mode) (def text-editor-pointer-down text-editor/text-editor-pointer-down) (def text-editor-pointer-move text-editor/text-editor-pointer-move) (def text-editor-pointer-up text-editor/text-editor-pointer-up) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 1cfb5b834c..23ce962584 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -143,6 +143,12 @@ (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y))) +(defn text-editor-toggle-overtype-mode + "Sets overtype mode" + [overtype] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_toggle_overtype_mode" overtype))) + (defn text-editor-pointer-down [{:keys [x y]}] (when wasm/context-initialized? diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 6bba86b3a3..5faa4c7fa5 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -326,6 +326,7 @@ pub struct TextEditorState { // selecting something with the pointer. pub is_pointer_selection_active: bool, pub is_click_event_skipped: bool, + pub is_overtype_mode: bool, pub active_shape_id: Option, pub cursor_visible: bool, pub last_blink_time: f64, @@ -345,6 +346,7 @@ impl TextEditorState { has_focus: false, is_pointer_selection_active: false, is_click_event_skipped: false, + is_overtype_mode: false, active_shape_id: None, cursor_visible: true, last_blink_time: 0.0, @@ -360,6 +362,7 @@ impl TextEditorState { self.last_blink_time = 0.0; self.selection.reset(); self.is_pointer_selection_active = false; + self.is_overtype_mode = false; self.pending_events.clear(); } @@ -370,6 +373,7 @@ impl TextEditorState { self.last_blink_time = 0.0; // self.selection.reset(); self.is_pointer_selection_active = false; + self.is_overtype_mode = false; self.pending_events.clear(); } @@ -380,6 +384,7 @@ impl TextEditorState { self.last_blink_time = 0.0; self.selection.reset(); self.is_pointer_selection_active = false; + self.is_overtype_mode = false; self.pending_events.clear(); } @@ -516,6 +521,14 @@ impl TextEditorState { self.push_event(TextEditorEvent::SelectionChanged); } + pub fn set_overtype_mode(&mut self, overtype_mode: bool) { + self.is_overtype_mode = overtype_mode; + } + + pub fn toggle_overtype_mode(&mut self) { + self.set_overtype_mode(!self.is_overtype_mode); + } + fn update_styles_from_selection(&mut self, text_content: &TextContent) -> bool { let paragraphs = text_content.paragraphs(); if paragraphs.is_empty() { diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index b0c57c5288..e0e44b27d9 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -276,6 +276,43 @@ pub fn find_text_span_at_offset(para: &Paragraph, char_offset: usize) -> Option< None } +pub fn replace_text_with_newlines( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, + text: &str, +) -> Option { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + let lines: Vec<&str> = normalized.split('\n').collect(); + if lines.is_empty() { + return None; + } + + let mut current_cursor = *cursor; + + if let Some(new_offset) = replace_text_at_cursor(text_content, ¤t_cursor, lines[0]) { + current_cursor = + TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset); + } else { + return None; + } + + for line in lines.iter().skip(1) { + if !split_paragraph_at_cursor(text_content, ¤t_cursor) { + break; + } + current_cursor = + TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); + if let Some(new_offset) = replace_text_at_cursor(text_content, ¤t_cursor, line) { + current_cursor = TextPositionWithAffinity::new_without_affinity( + current_cursor.paragraph, + new_offset, + ); + } + } + + Some(current_cursor) +} + /// Insert text at a cursor position, splitting on newlines into multiple paragraphs. /// Returns the final cursor position after insertion. pub fn insert_text_with_newlines( @@ -315,6 +352,8 @@ pub fn insert_text_with_newlines( Some(current_cursor) } + + /// Insert text at a cursor position. Returns the new character offset after insertion. pub fn insert_text_at_cursor( text_content: &mut TextContent, @@ -356,6 +395,58 @@ pub fn insert_text_at_cursor( Some(cursor.offset + text.chars().count()) } +/// Replace text at cursor position (overtype mode). Replaces N characters where N is the +/// length of the input text, returning the new cursor offset. +pub fn replace_text_at_cursor( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, + text: &str, +) -> Option { + let text_len = text.chars().count(); + if text_len == 0 { + return Some(cursor.offset); + } + + 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_len); + } + + let (span_idx, offset_in_span) = find_text_span_at_offset(para, cursor.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()); + + let end_byte_offset = new_text + .char_indices() + .nth(offset_in_span + text_len) + .map(|(i, _)| i) + .unwrap_or(new_text.len()); + + new_text.replace_range(byte_offset..end_byte_offset, text); + span.set_text(new_text); + + Some(cursor.offset + text_len) +} + /// Delete a range of text specified by a selection. pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) { let start = selection.start(); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 9db9910ee2..4cf5a92615 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -455,6 +455,15 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn text_editor_toggle_overtype_mode() -> Result<()> { + with_state_mut!(state, { + state.text_editor_state.toggle_overtype_mode(); + Ok(()) + }) +} + // FIXME: Review if all the return Ok(()) should be Err instead. #[no_mangle] #[wasm_error] @@ -491,11 +500,18 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { } 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); + if !state.text_editor_state.is_overtype_mode { + if let Some(new_cursor) = + text_helpers::insert_text_with_newlines(text_content, &cursor, &text) + { + state.text_editor_state.selection.set_caret(new_cursor); + } + } else { + if let Some(new_cursor) = + text_helpers::replace_text_with_newlines(text_content, &cursor, &text) + { + state.text_editor_state.selection.set_caret(new_cursor); + } } text_content.layout.paragraphs.clear();