#![allow(dead_code)] use macros::ToJs; use crate::shapes::{ Fill, FontFamily, TextAlign, TextContent, TextDecoration, TextDirection, TextPositionWithAffinity, TextTransform, VerticalAlign, }; 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, }; #[derive(Debug, Clone, Copy, Default)] pub struct TextSelection { pub anchor: TextPositionWithAffinity, pub focus: TextPositionWithAffinity, } impl TextSelection { pub fn new() -> Self { Self::default() } pub fn from_position_with_affinity(position: TextPositionWithAffinity) -> Self { Self { anchor: position, focus: position, } } pub fn is_collapsed(&self) -> bool { self.anchor == self.focus } pub fn is_selection(&self) -> bool { !self.is_collapsed() } pub fn reset(&mut self) { self.anchor.reset(); self.focus.reset(); } pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) { self.anchor = cursor; self.focus = cursor; } pub fn extend_to(&mut self, cursor: TextPositionWithAffinity) { self.focus = cursor; } pub fn collapse_to_focus(&mut self) { self.anchor = self.focus; } pub fn collapse_to_anchor(&mut self) { self.focus = self.anchor; } pub fn start(&self) -> TextPositionWithAffinity { if self.anchor.paragraph < self.focus.paragraph { self.anchor } else if self.anchor.paragraph > self.focus.paragraph { self.focus } else if self.anchor.offset <= self.focus.offset { self.anchor } else { self.focus } } pub fn end(&self) -> TextPositionWithAffinity { if self.anchor.paragraph > self.focus.paragraph { self.anchor } else if self.anchor.paragraph < self.focus.paragraph { self.focus } else if self.anchor.offset >= self.focus.offset { self.anchor } else { self.focus } } } /// Events that the text editor can emit for frontend synchronization #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, ToJs)] pub enum TextEditorEvent { None = 0, ContentChanged = 1, SelectionChanged = 2, StylesChanged = 3, NeedsLayout = 4, } /// 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_WIDTH: f32 = 1.0; const CURSOR_BLINK_INTERVAL_MS: f32 = 530.0; #[derive(Debug)] pub struct TextEditorStyles { pub vertical_align: VerticalAlign, pub text_align: Multiple, // Multiple pub text_direction: Multiple, // Multiple pub text_decoration: Multiple, pub text_transform: Multiple, pub font_family: Multiple, pub font_size: Multiple, pub font_weight: Multiple, pub font_variant_id: Multiple, pub line_height: Multiple, pub letter_spacing: Multiple, pub fills_are_multiple: bool, pub fills: Vec, } #[derive(Debug, Clone, Copy, PartialEq)] #[repr(u8)] pub enum MultipleState { Undefined = 0, Single = 1, Multiple = 2, } #[derive(Debug)] pub struct Multiple { state: MultipleState, value: Option, } impl Multiple { pub fn empty() -> Self { Self { state: MultipleState::Undefined, value: None, } } pub fn new(state: MultipleState, value: Option) -> Self { Self { state, value } } pub fn state(&self) -> &MultipleState { &self.state } pub fn value(&self) -> &Option { &self.value } pub fn is_undefined(&self) -> bool { self.state == MultipleState::Undefined } pub fn is_multiple(&self) -> bool { self.state == MultipleState::Multiple } pub fn is_single(&self) -> bool { self.state == MultipleState::Single } pub fn is_single_some(&self) -> bool { !self.is_undefined() && self.value.is_some() } pub fn is_single_none(&self) -> bool { !self.is_undefined() && self.value.is_none() } pub fn reset(&mut self) { self.state = MultipleState::Undefined; self.value = None; } pub fn set(&mut self, value: Option) { if self.state == MultipleState::Undefined || self.state == MultipleState::Multiple { self.state = MultipleState::Single; } self.value = value; } pub fn set_single(&mut self, value: Option) { self.state = MultipleState::Single; self.value = value; } pub fn set_multiple(&mut self) { self.state = MultipleState::Multiple; self.value = None; } pub fn merge(&mut self, value: Option) -> bool where T: PartialEq, { if self.state == MultipleState::Multiple { return false; } if self.state == MultipleState::Undefined { self.set_single(value); return true; } if self.value.as_ref() != value.as_ref() { self.set_multiple(); return false; } self.value = value; true } } impl TextEditorStyles { pub fn new() -> Self { Self { vertical_align: VerticalAlign::Top, text_align: Multiple::empty(), text_direction: Multiple::empty(), text_decoration: Multiple::empty(), text_transform: Multiple::empty(), font_family: Multiple::empty(), font_size: Multiple::empty(), font_weight: Multiple::empty(), font_variant_id: Multiple::empty(), line_height: Multiple::empty(), letter_spacing: Multiple::empty(), fills_are_multiple: false, fills: Vec::new(), } } pub fn reset(&mut self) { self.text_align.reset(); self.text_direction.reset(); self.text_decoration.reset(); self.text_transform.reset(); self.font_family.reset(); self.font_size.reset(); self.font_weight.reset(); self.font_variant_id.reset(); self.line_height.reset(); self.letter_spacing.reset(); self.fills_are_multiple = false; self.fills.clear(); } } impl Default for TextEditorStyles { fn default() -> Self { Self::new() } } pub struct TextEditorTheme { pub selection_color: Color, pub cursor_color: Color, pub cursor_width: f32, } pub struct TextComposition { pub previous: String, pub current: String, pub is_composing: bool, } impl TextComposition { pub fn new() -> Self { Self { previous: String::new(), current: String::new(), is_composing: false, } } pub fn start(&mut self) -> bool { if self.is_composing { return false; } self.is_composing = true; self.previous = String::new(); self.current = String::new(); true } pub fn update(&mut self, text: &str) -> bool { if !self.is_composing { self.is_composing = true; } self.previous = self.current.clone(); self.current = text.to_owned(); true } pub fn end(&mut self) -> bool { if !self.is_composing { return false; } self.is_composing = false; true } pub fn get_selection(&self, selection: &TextSelection) -> TextSelection { if self.previous.is_empty() { return *selection; } let focus = selection.focus; let previous_len = self.previous.chars().count(); let anchor = TextPositionWithAffinity::new_without_affinity( focus.paragraph, focus.offset + previous_len, ); TextSelection { anchor, focus } } } impl Default for TextComposition { fn default() -> Self { Self::new() } } pub struct TextEditorState { pub theme: TextEditorTheme, pub selection: TextSelection, pub composition: TextComposition, pub has_focus: bool, // This property indicates that we've started // 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_ms: f32, pub current_styles: TextEditorStyles, pending_events: Vec, } impl TextEditorState { pub fn new() -> Self { Self { 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_ms: 0.0, pending_events: Vec::new(), current_styles: TextEditorStyles::new(), } } pub fn focus(&mut self, shape_id: Uuid) { self.has_focus = true; self.active_shape_id = Some(shape_id); self.cursor_visible = true; 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(); } pub fn blur(&mut self) { self.has_focus = false; // self.active_shape_id = None; self.cursor_visible = false; 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(); } pub fn dispose(&mut self) { self.has_focus = false; self.active_shape_id = None; self.cursor_visible = false; 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(); } pub fn start_pointer_selection(&mut self) -> bool { if self.is_pointer_selection_active { return false; } self.is_pointer_selection_active = true; true } pub fn stop_pointer_selection(&mut self) -> bool { if !self.is_pointer_selection_active { return false; } self.is_pointer_selection_active = false; true } pub fn select_all(&mut self, text_content: &TextContent) -> bool { self.is_pointer_selection_active = false; self.set_caret_from_position(&TextPositionWithAffinity::empty()); let num_paragraphs = text_content.paragraphs().len() - 1; let Some(last_paragraph) = text_content.paragraphs().last() else { return false; }; #[allow(dead_code)] let _num_spans = last_paragraph.children().len() - 1; let Some(_last_text_span) = last_paragraph.children().last() else { return false; }; let mut offset = 0; for span in last_paragraph.children() { offset += span.text.len(); } 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); true } pub fn select_word_boundary( &mut self, 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; } let paragraph = ¶graphs[position.paragraph]; let paragraph_text: String = paragraph .children() .iter() .map(|span| span.text.as_str()) .collect(); let chars: Vec = paragraph_text.chars().collect(); if chars.is_empty() { self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( position.paragraph, 0, )); self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); return; } let mut offset = position.offset.min(chars.len()); if offset == chars.len() { offset = offset.saturating_sub(1); } else if !text_helpers::is_word_char(chars[offset]) && offset > 0 && text_helpers::is_word_char(chars[offset - 1]) { offset -= 1; } if !text_helpers::is_word_char(chars[offset]) { self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( position.paragraph, position.offset.min(chars.len()), )); self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); return; } let mut start = offset; while start > 0 && text_helpers::is_word_char(chars[start - 1]) { start -= 1; } let mut end = offset + 1; while end < chars.len() && text_helpers::is_word_char(chars[end]) { 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); } pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.set_caret(*position); self.push_event(TextEditorEvent::SelectionChanged); } pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) { self.selection.extend_to(*position); 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() { return false; } let start = self.selection.start(); if start.paragraph >= paragraphs.len() { return false; } let end = self.selection.end(); let end_paragraph = end.paragraph.min(paragraphs.len() - 1); self.current_styles.reset(); let mut has_selected_content = false; for (para_idx, paragraph) in paragraphs .iter() .enumerate() .take(end_paragraph + 1) .skip(start.paragraph) { let paragraph_char_count: usize = paragraph .children() .iter() .map(|span| span.text.chars().count()) .sum(); let range_start = if para_idx == start.paragraph { start.offset.min(paragraph_char_count) } else { 0 }; let range_end = if para_idx == end.paragraph { end.offset.min(paragraph_char_count) } else { paragraph_char_count }; if range_start >= range_end { continue; } has_selected_content = true; self.current_styles .text_align .merge(Some(paragraph.text_align())); let mut char_pos = 0; for span in paragraph.children() { let span_len = span.text.chars().count(); let span_start = char_pos; let span_end = char_pos + span_len; char_pos += span_len; let selected_start = range_start.max(span_start); let selected_end = range_end.min(span_end); if selected_start >= selected_end { continue; } self.current_styles .text_direction .merge(Some(span.text_direction)); self.current_styles .text_decoration .merge(span.text_decoration); self.current_styles .text_transform .merge(span.text_transform); self.current_styles .font_family .merge(Some(span.font_family)); self.current_styles.font_size.merge(Some(span.font_size)); self.current_styles .font_weight .merge(Some(span.font_weight)); self.current_styles .font_variant_id .merge(Some(span.font_variant_id)); self.current_styles .line_height .merge(Some(span.line_height)); self.current_styles .letter_spacing .merge(Some(span.letter_spacing)); if self.current_styles.fills.is_empty() { self.current_styles.fills.append(&mut span.fills.clone()); } else if self.current_styles.fills != span.fills { self.current_styles.fills_are_multiple = true; self.current_styles.fills.append(&mut span.fills.clone()); } } } has_selected_content } fn update_styles_from_caret(&mut self, text_content: &TextContent) -> bool { let focus = self.selection.focus; let paragraphs = text_content.paragraphs(); let Some(current_paragraph) = paragraphs.get(focus.paragraph) else { return false; }; let current_offset = focus.offset; let current_text_span = find_text_span_at_offset(current_paragraph, current_offset); self.current_styles.reset(); self.current_styles .text_align .set_single(Some(current_paragraph.text_align())); if let Some((text_span_index, _)) = current_text_span { if let Some(text_span) = current_paragraph.children().get(text_span_index) { self.current_styles .text_direction .set_single(Some(text_span.text_direction)); self.current_styles .text_decoration .set_single(text_span.text_decoration); self.current_styles .text_transform .set_single(text_span.text_transform); self.current_styles .font_family .set_single(Some(text_span.font_family)); self.current_styles .font_size .set_single(Some(text_span.font_size)); self.current_styles .font_weight .set_single(Some(text_span.font_weight)); self.current_styles .font_variant_id .set_single(Some(text_span.font_variant_id)); self.current_styles .line_height .set_single(Some(text_span.line_height)); self.current_styles .letter_spacing .set_single(Some(text_span.letter_spacing)); self.current_styles.fills = text_span.fills.clone(); } } else { self.current_styles .line_height .set_single(Some(current_paragraph.line_height())); self.current_styles .letter_spacing .set_single(Some(current_paragraph.letter_spacing())); } true } pub fn update_styles(&mut self, text_content: &TextContent) -> bool { if self.selection.is_selection() { let styles_were_updated = self.update_styles_from_selection(text_content); if styles_were_updated { self.push_event(TextEditorEvent::StylesChanged); } return styles_were_updated; } // It is a caret. let styles_were_updated = self.update_styles_from_caret(text_content); if styles_were_updated { self.push_event(TextEditorEvent::StylesChanged); } styles_were_updated } pub fn update_blink(&mut self, timestamp_ms: f32) { if !self.has_focus { return; } 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_ms; if elapsed >= CURSOR_BLINK_INTERVAL_MS { self.cursor_visible = !self.cursor_visible; self.last_blink_time_ms = timestamp_ms; } } pub fn reset_blink(&mut self) { self.cursor_visible = true; self.last_blink_time_ms = 0.0; } pub fn push_event(&mut self, event: TextEditorEvent) { if self.pending_events.last() != Some(&event) { self.pending_events.push(event); } } pub fn poll_event(&mut self) -> TextEditorEvent { self.pending_events.pop().unwrap_or(TextEditorEvent::None) } pub fn has_pending_events(&self) -> bool { !self.pending_events.is_empty() } pub fn delete_backward(&mut self, text_content: &mut TextContent, word_boundary: bool) { if self.selection.is_selection() { text_helpers::delete_selection_range(text_content, &self.selection); let start = self.selection.start(); let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); self.selection.set_caret(clamped); } else if word_boundary { let cursor = self.selection.focus; if let Some(new_cursor) = text_helpers::delete_word_before(text_content, &cursor) { self.selection.set_caret(new_cursor); } } else { let cursor = self.selection.focus; if let Some(new_cursor) = text_helpers::delete_char_before(text_content, &cursor) { self.selection.set_caret(new_cursor); } } text_content.layout.paragraphs.clear(); text_content.layout.paragraph_builders.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); self.push_event(TextEditorEvent::NeedsLayout); } pub fn delete_forward(&mut self, text_content: &mut TextContent, word_boundary: bool) { if self.selection.is_selection() { text_helpers::delete_selection_range(text_content, &self.selection); let start = self.selection.start(); let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); self.selection.set_caret(clamped); } else if word_boundary { let cursor = self.selection.focus; text_helpers::delete_word_after(text_content, &cursor); let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); self.selection.set_caret(clamped); } else { let cursor = self.selection.focus; text_helpers::delete_char_after(text_content, &cursor); let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); self.selection.set_caret(clamped); } text_content.layout.paragraphs.clear(); text_content.layout.paragraph_builders.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); self.push_event(TextEditorEvent::NeedsLayout); } pub fn insert_paragraph(&mut self, text_content: &mut TextContent) { if self.selection.is_selection() { text_helpers::delete_selection_range(text_content, &self.selection); let start = self.selection.start(); self.selection.set_caret(start); } 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); self.selection.set_caret(new_cursor); } text_content.layout.paragraphs.clear(); text_content.layout.paragraph_builders.clear(); self.reset_blink(); self.push_event(TextEditorEvent::ContentChanged); self.push_event(TextEditorEvent::NeedsLayout); } pub fn move_cursor( &mut self, text_content: &TextContent, direction: CursorDirection, word_boundary: bool, extend_selection: bool, ) -> bool { let paragraphs = text_content.paragraphs(); if paragraphs.is_empty() { return false; } let focus = self.selection.focus; // Get the text direction of the span at the current cursor position let text_span_text_direction = if focus.paragraph < paragraphs.len() { text_helpers::get_text_span_text_direction_at_offset( ¶graphs[focus.paragraph], focus.offset, ) } else { TextDirection::LTR }; // For horizontal navigation, swap Backward/Forward when in RTL text let adjusted_direction = if text_span_text_direction == TextDirection::RTL { match direction { CursorDirection::Backward => CursorDirection::Forward, CursorDirection::Forward => CursorDirection::Backward, other => other, } } else { direction }; let new_cursor = match adjusted_direction { CursorDirection::Backward => { text_helpers::move_cursor_backward(&focus, paragraphs, word_boundary) } CursorDirection::Forward => { text_helpers::move_cursor_forward(&focus, paragraphs, word_boundary) } CursorDirection::LineBefore => { text_helpers::move_cursor_up(&focus, paragraphs, text_content) } CursorDirection::LineAfter => { text_helpers::move_cursor_down(&focus, paragraphs, text_content) } CursorDirection::LineStart => text_helpers::move_cursor_line_start(&focus, paragraphs), CursorDirection::LineEnd => text_helpers::move_cursor_line_end(&focus, paragraphs), }; if extend_selection { self.selection.extend_to(new_cursor); } else { self.selection.set_caret(new_cursor); } self.update_styles(text_content); self.reset_blink(); self.push_event(TextEditorEvent::SelectionChanged); true } } impl Default for TextEditorState { fn default() -> Self { Self::new() } } fn is_word_char(c: char) -> bool { c.is_alphanumeric() || c == '_' }