mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 05:50:35 +00:00
🐛 Step over whole emojis in the text editor (#12202)
Caret moves, deletes and caret rects use grapheme boundaries. Closes #12188
This commit is contained in:
parent
d6f62d8d7d
commit
29ea6e49b6
7
render-wasm/Cargo.lock
generated
7
render-wasm/Cargo.lock
generated
@ -465,6 +465,7 @@ dependencies = [
|
||||
"macros",
|
||||
"skia-safe",
|
||||
"thiserror",
|
||||
"unicode-segmentation",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@ -702,6 +703,12 @@ version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.11.0"
|
||||
|
||||
@ -35,6 +35,7 @@ skia-safe = { version = "0.93.1", default-features = false, features = [
|
||||
"pdf",
|
||||
] }
|
||||
thiserror = "2.0.18"
|
||||
unicode-segmentation = "1.12.0"
|
||||
uuid = { version = "1.11.0", features = ["v4", "js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@ -162,7 +162,7 @@ fn calculate_cursor_rect(
|
||||
(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..para.char_utf16_len_at(0),
|
||||
0..para.char_offset_to_utf16(para.next_grapheme_offset(0)),
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
@ -173,10 +173,10 @@ fn calculate_cursor_rect(
|
||||
(0.0, 0.0, 1.0, laid_out_para.height())
|
||||
}
|
||||
} else if char_pos >= para_char_count {
|
||||
let last_char = para_char_count.saturating_sub(1);
|
||||
let last_start = para.char_offset_to_utf16(last_char);
|
||||
let last_start =
|
||||
para.char_offset_to_utf16(para.prev_grapheme_offset(para_char_count));
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
last_start..last_start + para.char_utf16_len_at(last_char),
|
||||
last_start..para.char_offset_to_utf16(para_char_count),
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
@ -196,7 +196,7 @@ fn calculate_cursor_rect(
|
||||
} else {
|
||||
let utf16_pos = para.char_offset_to_utf16(char_pos);
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
utf16_pos..utf16_pos + para.char_utf16_len_at(char_pos),
|
||||
utf16_pos..para.char_offset_to_utf16(para.next_grapheme_offset(char_pos)),
|
||||
RectHeightStyle::Max,
|
||||
RectWidthStyle::Tight,
|
||||
);
|
||||
|
||||
@ -25,6 +25,7 @@ use std::borrow::Cow;
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use super::FontFamily;
|
||||
use crate::math::Point;
|
||||
@ -1470,10 +1471,37 @@ impl Paragraph {
|
||||
low
|
||||
}
|
||||
|
||||
/// UTF-16 length of the character at `char_offset`, so a caret range covers
|
||||
/// the whole glyph.
|
||||
pub fn char_utf16_len_at(&self, char_offset: usize) -> usize {
|
||||
self.char_offset_to_utf16(char_offset + 1) - self.char_offset_to_utf16(char_offset)
|
||||
fn text(&self) -> String {
|
||||
self.children
|
||||
.iter()
|
||||
.map(|span| span.text.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Char offset of the grapheme boundary before `char_offset`, so emojis
|
||||
/// made of several chars are one step.
|
||||
pub fn prev_grapheme_offset(&self, char_offset: usize) -> usize {
|
||||
let mut boundary = 0;
|
||||
for grapheme in self.text().graphemes(true) {
|
||||
let next = boundary + grapheme.chars().count();
|
||||
if next >= char_offset {
|
||||
break;
|
||||
}
|
||||
boundary = next;
|
||||
}
|
||||
boundary
|
||||
}
|
||||
|
||||
/// Char offset of the grapheme boundary after `char_offset`.
|
||||
pub fn next_grapheme_offset(&self, char_offset: usize) -> usize {
|
||||
let mut boundary = 0;
|
||||
for grapheme in self.text().graphemes(true) {
|
||||
boundary += grapheme.chars().count();
|
||||
if boundary > char_offset {
|
||||
break;
|
||||
}
|
||||
}
|
||||
boundary
|
||||
}
|
||||
|
||||
pub fn line_height(&self) -> f32 {
|
||||
@ -2166,16 +2194,29 @@ mod tests {
|
||||
|
||||
assert_eq!(para.char_offset_to_utf16(4), 4);
|
||||
assert_eq!(para.char_offset_to_utf16(6), 7);
|
||||
assert_eq!(para.char_utf16_len_at(4), 2);
|
||||
assert_eq!(para.char_offset_to_utf16(5), 6);
|
||||
assert_eq!(para.utf16_offset_to_char(7), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_utf16_len_at_covers_the_whole_glyph() {
|
||||
let para = test_paragraph(&["a😀b"]);
|
||||
assert_eq!(para.char_utf16_len_at(0), 1);
|
||||
assert_eq!(para.char_utf16_len_at(1), 2);
|
||||
assert_eq!(para.char_utf16_len_at(2), 1);
|
||||
fn grapheme_offsets_cross_whole_emojis() {
|
||||
let para = test_paragraph(&["A👍🏽👨\u{200d}👩\u{200d}👧e\u{301}"]);
|
||||
let boundaries = [0, 1, 3, 8, 10];
|
||||
for pair in boundaries.windows(2) {
|
||||
assert_eq!(para.next_grapheme_offset(pair[0]), pair[1]);
|
||||
assert_eq!(para.prev_grapheme_offset(pair[1]), pair[0]);
|
||||
}
|
||||
assert_eq!(para.next_grapheme_offset(2), 3);
|
||||
assert_eq!(para.prev_grapheme_offset(2), 1);
|
||||
assert_eq!(para.next_grapheme_offset(10), 10);
|
||||
assert_eq!(para.prev_grapheme_offset(0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grapheme_offsets_cross_an_emoji_split_across_spans() {
|
||||
let para = test_paragraph(&["A👍", "🏽B"]);
|
||||
assert_eq!(para.next_grapheme_offset(1), 3);
|
||||
assert_eq!(para.prev_grapheme_offset(3), 1);
|
||||
}
|
||||
|
||||
fn sample_text_content() -> TextContent {
|
||||
|
||||
@ -49,7 +49,7 @@ pub fn move_cursor_backward(
|
||||
if cursor.offset > 0 {
|
||||
return TextPositionWithAffinity::new_downstream_affinity(
|
||||
cursor.paragraph,
|
||||
cursor.offset - 1,
|
||||
paragraphs[cursor.paragraph].prev_grapheme_offset(cursor.offset),
|
||||
);
|
||||
}
|
||||
if cursor.paragraph > 0 {
|
||||
@ -126,7 +126,7 @@ pub fn move_cursor_forward(
|
||||
if cursor.offset < char_count {
|
||||
return TextPositionWithAffinity::new_downstream_affinity(
|
||||
cursor.paragraph,
|
||||
cursor.offset + 1,
|
||||
para.next_grapheme_offset(cursor.offset),
|
||||
);
|
||||
}
|
||||
if cursor.paragraph < paragraphs.len() - 1 {
|
||||
@ -582,7 +582,7 @@ pub fn delete_char_before(
|
||||
if cursor.offset > 0 {
|
||||
let paragraphs = text_content.paragraphs_mut();
|
||||
let para = &mut paragraphs[cursor.paragraph];
|
||||
let delete_pos = cursor.offset - 1;
|
||||
let delete_pos = para.prev_grapheme_offset(cursor.offset);
|
||||
delete_range_in_paragraph(para, delete_pos, cursor.offset);
|
||||
Some(TextPositionWithAffinity::new_downstream_affinity(
|
||||
cursor.paragraph,
|
||||
@ -766,7 +766,8 @@ pub fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWi
|
||||
|
||||
if cursor.offset < para_len {
|
||||
let para = &mut paragraphs[cursor.paragraph];
|
||||
delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1);
|
||||
let end = para.next_grapheme_offset(cursor.offset);
|
||||
delete_range_in_paragraph(para, cursor.offset, end);
|
||||
} else if cursor.paragraph < paragraphs.len() - 1 {
|
||||
let next_para_idx = cursor.paragraph + 1;
|
||||
let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect();
|
||||
@ -946,6 +947,38 @@ mod tests {
|
||||
assert_eq!(text_of(&content), "ab");
|
||||
}
|
||||
|
||||
fn caret(paragraph: usize, offset: usize) -> TextPositionWithAffinity {
|
||||
TextPositionWithAffinity::new_downstream_affinity(paragraph, offset)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_char_before_deletes_a_whole_emoji() {
|
||||
for emoji in ["😀", "👍🏽", "👨\u{200d}👩\u{200d}👧", "🇪🇸"] {
|
||||
let text = format!("A{emoji}");
|
||||
let len = text.chars().count();
|
||||
let mut content = content(vec![paragraph(&[text.as_str()])]);
|
||||
let new_caret = delete_char_before(&mut content, &caret(0, len));
|
||||
assert_eq!(text_of(&content), "A");
|
||||
assert_eq!(new_caret, Some(caret(0, 1)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_char_after_deletes_a_whole_emoji() {
|
||||
let mut content = content(vec![paragraph(&["A👍🏽B"])]);
|
||||
delete_char_after(&mut content, &caret(0, 1));
|
||||
assert_eq!(text_of(&content), "AB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_cursor_steps_over_a_whole_emoji() {
|
||||
let paragraphs = vec![paragraph(&["A👍🏽B"])];
|
||||
let forward = move_cursor_forward(&caret(0, 1), ¶graphs, false);
|
||||
assert_eq!(forward, caret(0, 3));
|
||||
let backward = move_cursor_backward(&caret(0, 3), ¶graphs, false);
|
||||
assert_eq!(backward, caret(0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_selection_range_spanning_several_spans() {
|
||||
let mut content = content(vec![paragraph(&["Añ", "adir"])]);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user