🐛 Fix paragraph selection (#11493)

This commit is contained in:
Elena Torró 2026-09-03 16:17:38 +02:00 committed by GitHub
parent fb22c1547c
commit eb8d3a2fef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 133 additions and 70 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<char> = 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(&paragraphs[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);
}

View File

@ -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(&paragraphs[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(&paragraphs[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 = &paragraphs[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(&paragraphs[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(&paragraphs[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(&paragraphs[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(&paragraphs[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, &current_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, &current_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, &current_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, &current_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),
}
}

View File

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