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 96c3253c64..694ea6215a 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..b547169ae2 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 + "Toggles overtype mode" + [] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_toggle_overtype_mode"))) + (defn text-editor-pointer-down [{:keys [x y]}] (when wasm/context-initialized? diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 9478e8a0eb..10e6e11725 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -3,7 +3,7 @@ use crate::shapes::{Shape, TextContent, Type, VerticalAlign}; use crate::state::{TextEditorState, TextSelection}; use crate::view::Viewbox; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; -use skia_safe::{BlendMode, Canvas, Paint, Rect}; +use skia_safe::{BlendMode, Canvas, Color, Paint, Rect}; pub fn render_overlay( canvas: &Canvas, @@ -48,11 +48,26 @@ fn render_cursor( }; let mut cursor_rect = Rect::new_empty(); - cursor_rect.set_xywh(rect.x(), rect.y(), 1.5 / zoom, rect.height()); + cursor_rect.set_xywh( + rect.x(), + rect.y(), + if editor_state.is_overtype_mode { + rect.width() + } else { + editor_state.theme.cursor_width / zoom + }, + rect.height(), + ); let mut paint = Paint::default(); - paint.set_color(editor_state.theme.cursor_color); - paint.set_anti_alias(true); + paint.set_anti_alias(false); + if editor_state.is_overtype_mode { + paint.set_blend_mode(BlendMode::Exclusion); + paint.set_color(Color::WHITE); + } else { + paint.set_blend_mode(BlendMode::SrcOver); + paint.set_color(editor_state.theme.cursor_color); + } let shape_matrix = shape.get_matrix(); canvas.save(); @@ -132,9 +147,9 @@ fn calculate_cursor_rect( .map(|span| span.text.chars().count()) .sum(); - let (cursor_x, cursor_y, cursor_height) = if para_char_count == 0 { + let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 { // Empty paragraph - use default height - (0.0, 0.0, laid_out_para.height()) + (0.0, 0.0, 1.0, laid_out_para.height()) } else if char_pos == 0 { let rects = laid_out_para.get_rects_for_range( 0..1, @@ -143,9 +158,9 @@ fn calculate_cursor_rect( ); if !rects.is_empty() { let r = &rects[0].rect; - (r.left(), r.top(), r.height()) + (r.left(), r.top(), r.width(), r.height()) } else { - (0.0, 0.0, laid_out_para.height()) + (0.0, 0.0, 1.0, laid_out_para.height()) } } else if char_pos >= para_char_count { let rects = laid_out_para.get_rects_for_range( @@ -155,9 +170,14 @@ fn calculate_cursor_rect( ); if !rects.is_empty() { let r = &rects[0].rect; - (r.right(), r.top(), r.height()) + (r.right(), r.top(), r.width(), r.height()) } else { - (laid_out_para.longest_line(), 0.0, laid_out_para.height()) + ( + laid_out_para.longest_line(), + 0.0, + 1.0, + laid_out_para.height(), + ) } } else { let rects = laid_out_para.get_rects_for_range( @@ -167,18 +187,18 @@ fn calculate_cursor_rect( ); if !rects.is_empty() { let r = &rects[0].rect; - (r.left(), r.top(), r.height()) + (r.left(), r.top(), r.width(), r.height()) } else { // Fallback: use glyph position let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); - (pos.position as f32, 0.0, laid_out_para.height()) + (pos.position as f32, 0.0, 1.0, laid_out_para.height()) } }; return Some(Rect::from_xywh( cursor_x, y_offset + cursor_y, - 1.0, // cursor_width + cursor_width, // cursor_width cursor_height, )); } diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 6bba86b3a3..e604f6c4a3 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -101,7 +101,8 @@ pub enum TextEditorEvent { /// FIXME: It should be better to get these constants from the frontend through the API. const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184); const CURSOR_COLOR: Color = Color::BLACK; -const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0; +const CURSOR_WIDTH: f32 = 1.0; +const CURSOR_BLINK_INTERVAL_MS: f32 = 530.0; #[derive(Debug)] pub struct TextEditorStyles { @@ -257,6 +258,7 @@ impl TextEditorStyles { pub struct TextEditorTheme { pub selection_color: Color, pub cursor_color: Color, + pub cursor_width: f32, } pub struct TextComposition { @@ -326,9 +328,10 @@ 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, + pub last_blink_time_ms: f32, pub current_styles: TextEditorStyles, pending_events: Vec, } @@ -339,15 +342,17 @@ impl TextEditorState { theme: TextEditorTheme { selection_color: SELECTION_COLOR, cursor_color: CURSOR_COLOR, + cursor_width: CURSOR_WIDTH, }, selection: TextSelection::new(), composition: TextComposition::new(), 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, + last_blink_time_ms: 0.0, pending_events: Vec::new(), current_styles: TextEditorStyles::new(), } @@ -357,9 +362,10 @@ impl TextEditorState { self.has_focus = true; self.active_shape_id = Some(shape_id); self.cursor_visible = true; - self.last_blink_time = 0.0; + self.last_blink_time_ms = 0.0; self.selection.reset(); self.is_pointer_selection_active = false; + self.is_overtype_mode = false; self.pending_events.clear(); } @@ -367,9 +373,10 @@ impl TextEditorState { self.has_focus = false; // self.active_shape_id = None; self.cursor_visible = false; - self.last_blink_time = 0.0; + self.last_blink_time_ms = 0.0; // self.selection.reset(); self.is_pointer_selection_active = false; + self.is_overtype_mode = false; self.pending_events.clear(); } @@ -377,9 +384,10 @@ impl TextEditorState { self.has_focus = false; self.active_shape_id = None; self.cursor_visible = false; - self.last_blink_time = 0.0; + self.last_blink_time_ms = 0.0; self.selection.reset(); self.is_pointer_selection_active = false; + self.is_overtype_mode = false; self.pending_events.clear(); } @@ -516,6 +524,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() { @@ -687,27 +703,27 @@ impl TextEditorState { styles_were_updated } - pub fn update_blink(&mut self, timestamp_ms: f64) { + pub fn update_blink(&mut self, timestamp_ms: f32) { if !self.has_focus { return; } - if self.last_blink_time == 0.0 { - self.last_blink_time = timestamp_ms; + if self.last_blink_time_ms == 0.0 { + self.last_blink_time_ms = timestamp_ms; self.cursor_visible = true; return; } - let elapsed = timestamp_ms - self.last_blink_time; + let elapsed = timestamp_ms - self.last_blink_time_ms; if elapsed >= CURSOR_BLINK_INTERVAL_MS { self.cursor_visible = !self.cursor_visible; - self.last_blink_time = timestamp_ms; + self.last_blink_time_ms = timestamp_ms; } } pub fn reset_blink(&mut self) { self.cursor_visible = true; - self.last_blink_time = 0.0; + self.last_blink_time_ms = 0.0; } pub fn push_event(&mut self, event: TextEditorEvent) { diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index b0c57c5288..67044b4788 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( @@ -356,6 +393,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..0fdc3f11bb 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,16 @@ 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) + 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); + state.text_editor_state.selection.set_caret(new_cursor); } text_content.layout.paragraphs.clear(); @@ -876,7 +890,7 @@ pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 { } #[no_mangle] -pub extern "C" fn text_editor_update_blink(timestamp_ms: f64) { +pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) { with_state_mut!(state, { state.text_editor_state.update_blink(timestamp_ms); });