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 b61c7b4c07..7f0806fd34 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 @@ -159,6 +159,10 @@ (or (.-isComposing native) (= 229 (.-keyCode event))))) +(defn- triple-click? + [^js native-event] + (>= (.-detail native-event) 3)) + (defn- input-surface-class "Class list for the contenteditable capture surface. @@ -492,7 +496,9 @@ (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 off-pt) + (if (triple-click? native-event) + (wasm.api/text-editor-select-paragraph off-pt) + (wasm.api/text-editor-set-cursor-from-offset off-pt)) (wasm.api/render-text-editor-overlay!)))) on-double-click diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index f09e2d7db2..ee37ae995b 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -311,6 +311,7 @@ (def text-editor-get-active-shape-id text-editor/text-editor-get-active-shape-id) (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-select-paragraph text-editor/text-editor-select-paragraph) (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 ae914a31d8..cbf6bf6bbd 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -450,6 +450,11 @@ (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_select_word_boundary" x y))) +(defn text-editor-select-paragraph + [{:keys [x y]}] + (when (wasm/ready?) + (h/call wasm/internal-module "_text_editor_select_paragraph" x y))) + (defn text-editor-blur [] (when (wasm/ready?) diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 0a64ee037a..1e9ae16aee 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -192,7 +192,7 @@ impl TextPositionWithAffinity { } } - pub fn new_without_affinity(paragraph: usize, offset: usize) -> Self { + pub fn new_downstream_affinity(paragraph: usize, offset: usize) -> Self { Self { position_with_affinity: PositionWithAffinity { position: offset as i32, @@ -203,6 +203,17 @@ impl TextPositionWithAffinity { } } + pub fn new_upstream_affinity(paragraph: usize, offset: usize) -> Self { + Self { + position_with_affinity: PositionWithAffinity { + position: offset as i32, + affinity: Affinity::Upstream, + }, + paragraph, + offset, + } + } + pub fn reset(&mut self) { self.position_with_affinity.position = 0; self.position_with_affinity.affinity = Affinity::Downstream; diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 0c14c69ae0..73711a93af 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -9,10 +9,7 @@ use crate::shapes::{ use crate::uuid::Uuid; use crate::wasm::text::helpers::{self as text_helpers, find_text_span_at_offset}; use crate::wasm::text_editor::CursorDirection; -use skia_safe::{ - textlayout::{Affinity, PositionWithAffinity}, - Color, -}; +use skia_safe::Color; #[derive(Debug, Clone, Copy, Default)] pub struct TextSelection { @@ -329,7 +326,7 @@ impl TextComposition { let focus = selection.focus; let previous_len = self.previous.chars().count(); - let anchor = TextPositionWithAffinity::new_without_affinity( + let anchor = TextPositionWithAffinity::new_downstream_affinity( focus.paragraph, focus.offset + previous_len, ); @@ -437,9 +434,22 @@ impl TextEditorState { true } - pub fn select_all(&mut self, text_content: &TextContent) -> bool { + fn select_range( + &mut self, + text_content: &TextContent, + start: &TextPositionWithAffinity, + end: &TextPositionWithAffinity, + ) { self.is_pointer_selection_active = false; - self.set_caret_from_position(&TextPositionWithAffinity::empty()); + self.is_click_event_skipped = false; + self.set_caret_from_position(start); + self.extend_selection_from_position(end); + self.update_styles(text_content); + self.reset_blink(); + self.push_event(TextEditorEvent::SelectionChanged); + } + + pub fn select_all(&mut self, text_content: &TextContent) -> bool { let num_paragraphs = text_content.paragraphs().len().saturating_sub(1); let Some(last_paragraph) = text_content.paragraphs().last() else { return false; @@ -449,17 +459,11 @@ impl TextEditorState { }; // Offsets are counted in characters, not bytes. let offset = text_helpers::paragraph_char_count(last_paragraph); - self.extend_selection_from_position(&TextPositionWithAffinity::new( - PositionWithAffinity { - position: offset as i32, - affinity: Affinity::Upstream, - }, - num_paragraphs, - offset, - )); - self.update_styles(text_content); - self.reset_blink(); - self.push_event(TextEditorEvent::SelectionChanged); + self.select_range( + text_content, + &TextPositionWithAffinity::empty(), + &TextPositionWithAffinity::new_upstream_affinity(num_paragraphs, offset), + ); true } @@ -469,8 +473,6 @@ impl TextEditorState { text_content: &TextContent, position: &TextPositionWithAffinity, ) { - self.is_pointer_selection_active = false; - let paragraphs = text_content.paragraphs(); if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { return; @@ -485,7 +487,7 @@ impl TextEditorState { let chars: Vec = paragraph_text.chars().collect(); if chars.is_empty() { - self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + self.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity( position.paragraph, 0, )); @@ -507,7 +509,7 @@ impl TextEditorState { } if !text_helpers::is_word_char(chars[offset]) { - self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( + self.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity( position.paragraph, position.offset.min(chars.len()), )); @@ -527,17 +529,31 @@ impl TextEditorState { 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.update_styles(text_content); - self.reset_blink(); - self.push_event(TextEditorEvent::SelectionChanged); + self.select_range( + text_content, + &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, start), + &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, end), + ); + } + + pub fn select_paragraph( + &mut self, + text_content: &TextContent, + position: &TextPositionWithAffinity, + ) { + let paragraphs = text_content.paragraphs(); + if paragraphs.is_empty() || position.paragraph >= paragraphs.len() { + return; + } + + // Offsets are counted in characters, not bytes. + let offset = text_helpers::paragraph_char_count(¶graphs[position.paragraph]); + + self.select_range( + text_content, + &TextPositionWithAffinity::new_downstream_affinity(position.paragraph, 0), + &TextPositionWithAffinity::new_upstream_affinity(position.paragraph, offset), + ); } pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { @@ -838,7 +854,7 @@ impl TextEditorState { let cursor = self.selection.focus; if text_helpers::split_paragraph_at_cursor(text_content, &cursor) { let new_cursor = - TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph + 1, 0); self.selection.set_caret(new_cursor); } diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs index fedfdba215..95bec9f9a6 100644 --- a/render-wasm/src/wasm/text/helpers.rs +++ b/render-wasm/src/wasm/text/helpers.rs @@ -29,14 +29,14 @@ pub fn clamp_cursor( paragraphs: &[Paragraph], ) -> TextPositionWithAffinity { if paragraphs.is_empty() { - return TextPositionWithAffinity::new_without_affinity(0, 0); + return TextPositionWithAffinity::new_downstream_affinity(0, 0); } let para_idx = position.paragraph.min(paragraphs.len() - 1); let para_len = paragraph_char_count(¶graphs[para_idx]); let char_offset = position.offset.min(para_len); - TextPositionWithAffinity::new_without_affinity(para_idx, char_offset) + TextPositionWithAffinity::new_downstream_affinity(para_idx, char_offset) } /// Move cursor left by one character. @@ -47,7 +47,7 @@ pub fn move_cursor_backward( ) -> TextPositionWithAffinity { if !word_boundary { if cursor.offset > 0 { - return TextPositionWithAffinity::new_without_affinity( + return TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, cursor.offset - 1, ); @@ -55,7 +55,7 @@ pub fn move_cursor_backward( if cursor.paragraph > 0 { let prev_para = cursor.paragraph - 1; let char_count = paragraph_char_count(¶graphs[prev_para]); - return TextPositionWithAffinity::new_without_affinity(prev_para, char_count); + return TextPositionWithAffinity::new_downstream_affinity(prev_para, char_count); } return *cursor; } @@ -111,7 +111,7 @@ pub fn move_cursor_backward( } } - TextPositionWithAffinity::new_without_affinity(para_idx, offset) + TextPositionWithAffinity::new_downstream_affinity(para_idx, offset) } /// Move cursor right by one character. @@ -124,13 +124,13 @@ pub fn move_cursor_forward( let para = ¶graphs[cursor.paragraph]; let char_count = paragraph_char_count(para); if cursor.offset < char_count { - return TextPositionWithAffinity::new_without_affinity( + return TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, cursor.offset + 1, ); } if cursor.paragraph < paragraphs.len() - 1 { - return TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); + return TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph + 1, 0); } return *cursor; } @@ -185,7 +185,7 @@ pub fn move_cursor_forward( } } - TextPositionWithAffinity::new_without_affinity(para_idx, offset) + TextPositionWithAffinity::new_downstream_affinity(para_idx, offset) } /// Move cursor up by one line. @@ -203,9 +203,9 @@ pub fn move_cursor_up( let prev_para = cursor.paragraph - 1; let char_count = paragraph_char_count(¶graphs[prev_para]); let new_offset = cursor.offset.min(char_count); - TextPositionWithAffinity::new_without_affinity(prev_para, new_offset) + TextPositionWithAffinity::new_downstream_affinity(prev_para, new_offset) } else { - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, 0) } } @@ -224,10 +224,10 @@ pub fn move_cursor_down( let next_para = cursor.paragraph + 1; let char_count = paragraph_char_count(¶graphs[next_para]); let new_offset = cursor.offset.min(char_count); - TextPositionWithAffinity::new_without_affinity(next_para, new_offset) + TextPositionWithAffinity::new_downstream_affinity(next_para, new_offset) } else { let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, char_count) } } @@ -237,7 +237,7 @@ pub fn move_cursor_line_start( _paragraphs: &[Paragraph], ) -> TextPositionWithAffinity { // TODO: Implement proper line-start using line metrics - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, 0) } /// Move cursor to end of current line. @@ -247,7 +247,7 @@ pub fn move_cursor_line_end( ) -> TextPositionWithAffinity { // TODO: Implement proper line-end using line metrics let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) + TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph, char_count) } pub fn is_word_char(c: char) -> bool { @@ -299,7 +299,7 @@ pub fn replace_text_with_newlines( 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); + TextPositionWithAffinity::new_downstream_affinity(current_cursor.paragraph, new_offset); } else { return None; } @@ -309,9 +309,9 @@ pub fn replace_text_with_newlines( break; } current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); + TextPositionWithAffinity::new_downstream_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 = TextPositionWithAffinity::new_downstream_affinity( current_cursor.paragraph, new_offset, ); @@ -338,7 +338,7 @@ pub fn insert_text_with_newlines( if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, lines[0]) { current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset); + TextPositionWithAffinity::new_downstream_affinity(current_cursor.paragraph, new_offset); } else { return None; } @@ -348,9 +348,9 @@ pub fn insert_text_with_newlines( break; } current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); + TextPositionWithAffinity::new_downstream_affinity(current_cursor.paragraph + 1, 0); if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, line) { - current_cursor = TextPositionWithAffinity::new_without_affinity( + current_cursor = TextPositionWithAffinity::new_downstream_affinity( current_cursor.paragraph, new_offset, ); @@ -584,7 +584,7 @@ pub fn delete_char_before( let para = &mut paragraphs[cursor.paragraph]; let delete_pos = cursor.offset - 1; delete_range_in_paragraph(para, delete_pos, cursor.offset); - Some(TextPositionWithAffinity::new_without_affinity( + Some(TextPositionWithAffinity::new_downstream_affinity( cursor.paragraph, delete_pos, )) @@ -603,7 +603,7 @@ pub fn delete_char_before( paragraphs.remove(cursor.paragraph); - Some(TextPositionWithAffinity::new_without_affinity( + Some(TextPositionWithAffinity::new_downstream_affinity( prev_para_idx, prev_para_len, )) @@ -675,13 +675,13 @@ pub fn delete_word_before( } let selection = TextSelection { - anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset), - focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset), + anchor: TextPositionWithAffinity::new_downstream_affinity(start_paragraph, start_offset), + focus: TextPositionWithAffinity::new_downstream_affinity(end_paragraph, end_offset), }; delete_selection_range(text_content, &selection); - Some(TextPositionWithAffinity::new_without_affinity( + Some(TextPositionWithAffinity::new_downstream_affinity( start_paragraph, start_offset, )) @@ -748,8 +748,8 @@ pub fn delete_word_after(text_content: &mut TextContent, cursor: &TextPositionWi } let selection = TextSelection { - anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset), - focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset), + anchor: TextPositionWithAffinity::new_downstream_affinity(start_paragraph, start_offset), + focus: TextPositionWithAffinity::new_downstream_affinity(end_paragraph, end_offset), }; delete_selection_range(text_content, &selection); @@ -912,8 +912,8 @@ mod tests { fn selection(start: (usize, usize), end: (usize, usize)) -> TextSelection { TextSelection { - anchor: TextPositionWithAffinity::new_without_affinity(start.0, start.1), - focus: TextPositionWithAffinity::new_without_affinity(end.0, end.1), + anchor: TextPositionWithAffinity::new_downstream_affinity(start.0, start.1), + focus: TextPositionWithAffinity::new_downstream_affinity(end.0, end.1), } } diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 87e0c61899..258acd6601 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -5,8 +5,8 @@ use crate::math::{Matrix, Point}; use crate::mem; use crate::render::text_editor as text_editor_render; use crate::render::SurfaceId; -use crate::shapes::{TextAlign, TextPositionWithAffinity, Type, VerticalAlign}; -use crate::state::{State, TextEditorEvent}; +use crate::shapes::{TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; +use crate::state::{State, TextEditorEvent, TextEditorState}; use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; use crate::uuid::Uuid; @@ -133,8 +133,10 @@ pub extern "C" fn text_editor_select_all() -> bool { }) } -#[no_mangle] -pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { +fn with_active_text_at_point(x: f32, y: f32, apply: F) +where + F: FnOnce(&mut TextEditorState, &TextContent, &TextPositionWithAffinity), +{ with_state!(state, { if !get_text_editor_state().has_focus { return; @@ -154,11 +156,33 @@ pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { let point = Point::new(x, y); if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) { - get_text_editor_state().select_word_boundary(text_content, &position); + apply(get_text_editor_state(), text_content, &position); } }) } +#[no_mangle] +pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { + with_active_text_at_point(x, y, |editor, text_content, position| { + editor.select_word_boundary(text_content, position) + }) +} + +#[no_mangle] +pub extern "C" fn text_editor_select_paragraph(x: f32, y: f32) { + // A drag that produced a range must survive the trailing click; a jitter + // that left the caret collapsed must not suppress the paragraph select. + let editor = get_text_editor_state(); + if editor.is_click_event_skipped && editor.selection.is_selection() { + editor.is_click_event_skipped = false; + return; + } + + with_active_text_at_point(x, y, |editor, text_content, position| { + editor.select_paragraph(text_content, position) + }) +} + #[no_mangle] pub extern "C" fn text_editor_poll_event() -> u8 { get_text_editor_state().poll_event() as u8