diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4ba57dde95..fa21646cac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -146,11 +146,18 @@ jobs: name: "Frontend Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest + needs: test-render-wasm steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Restore shared.js + uses: actions/cache/restore@v4 + with: + key: "render-wasm-shared-js-${{ github.sha }}" + path: frontend/src/app/render_wasm/api/shared.js + - name: Unit Tests working-directory: ./frontend run: | @@ -187,6 +194,19 @@ jobs: run: | ./test + - name: Copy shared.js artifact + working-directory: ./render-wasm + run: | + SHARED_FILE=$(find target -name render_wasm_shared.js | head -n 1); + mkdir -p ../frontend/src/app/render_wasm/api; + cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js; + + - name: Cache shared.js + uses: actions/cache@v4 + with: + key: "render-wasm-shared-js-${{ github.sha }}" + path: frontend/src/app/render_wasm/api/shared.js + test-backend: name: "Backend Tests" runs-on: penpot-runner-02 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 d0cb12caeb..576854c163 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 @@ -162,7 +162,7 @@ (= key "Backspace") (do (dom/prevent-default event) - (text-editor/text-editor-delete-backward) + (text-editor/text-editor-delete-backward ctrl?) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-delete-backward")) @@ -170,7 +170,7 @@ (= key "Delete") (do (dom/prevent-default event) - (text-editor/text-editor-delete-forward) + (text-editor/text-editor-delete-forward ctrl?) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-delete-forward")) @@ -178,37 +178,37 @@ (= key "ArrowLeft") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 0 shift?) + (text-editor/text-editor-move-cursor 0 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) (= key "ArrowRight") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 1 shift?) + (text-editor/text-editor-move-cursor 1 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) (= key "ArrowUp") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 2 shift?) + (text-editor/text-editor-move-cursor 2 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) (= key "ArrowDown") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 3 shift?) + (text-editor/text-editor-move-cursor 3 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) (= key "Home") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 4 shift?) + (text-editor/text-editor-move-cursor 4 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) (= key "End") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 5 shift?) + (text-editor/text-editor-move-cursor 5 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) ;; Let contenteditable handle text input via on-input diff --git a/frontend/src/app/render_wasm/api/shared.js b/frontend/src/app/render_wasm/api/shared.js deleted file mode 100644 index 250cc7bf78..0000000000 --- a/frontend/src/app/render_wasm/api/shared.js +++ /dev/null @@ -1,256 +0,0 @@ -export const GrowType = { - "fixed": 0, - "auto-width": 1, - "auto-height": 2, -}; - -export const RawBlendMode = { - "normal": 3, - "screen": 14, - "overlay": 15, - "darken": 16, - "lighten": 17, - "color-dodge": 18, - "color-burn": 19, - "hard-light": 20, - "soft-light": 21, - "difference": 22, - "exclusion": 23, - "multiply": 24, - "hue": 25, - "saturation": 26, - "color": 27, - "luminosity": 28, -}; - -export const RawBlurType = { - "layer-blur": 0, -}; - -export const RawFillData = { - "solid": 0, - "linear": 1, - "radial": 2, - "image": 3, -}; - -export const RawFontStyle = { - "normal": 0, - "italic": 1, -}; - -export const RawAlignItems = { - "start": 0, - "end": 1, - "center": 2, - "stretch": 3, -}; - -export const RawAlignContent = { - "start": 0, - "end": 1, - "center": 2, - "space-between": 3, - "space-around": 4, - "space-evenly": 5, - "stretch": 6, -}; - -export const RawJustifyItems = { - "start": 0, - "end": 1, - "center": 2, - "stretch": 3, -}; - -export const RawJustifyContent = { - "start": 0, - "end": 1, - "center": 2, - "space-between": 3, - "space-around": 4, - "space-evenly": 5, - "stretch": 6, -}; - -export const RawJustifySelf = { - "none": 0, - "auto": 1, - "start": 2, - "end": 3, - "center": 4, - "stretch": 5, -}; - -export const RawAlignSelf = { - "none": 0, - "auto": 1, - "start": 2, - "end": 3, - "center": 4, - "stretch": 5, -}; - -export const RawVerticalAlign = { - "top": 0, - "center": 1, - "bottom": 2, -}; - -export const RawConstraintH = { - "left": 0, - "right": 1, - "leftright": 2, - "center": 3, - "scale": 4, -}; - -export const RawConstraintV = { - "top": 0, - "bottom": 1, - "topbottom": 2, - "center": 3, - "scale": 4, -}; - -export const RawFlexDirection = { - "row": 0, - "row-reverse": 1, - "column": 2, - "column-reverse": 3, -}; - -export const RawWrapType = { - "wrap": 0, - "nowrap": 1, -}; - -export const RawGridDirection = { - "row": 0, - "column": 1, -}; - -export const RawGridTrackType = { - "percent": 0, - "flex": 1, - "auto": 2, - "fixed": 3, -}; - -export const RawSizing = { - "fill": 0, - "fix": 1, - "auto": 2, -}; - -export const RawBoolType = { - "union": 0, - "difference": 1, - "intersection": 2, - "exclusion": 3, -}; - -export const RawSegmentData = { - "move-to": 1, - "line-to": 2, - "curve-to": 3, - "close": 4, -}; - -export const RawShadowStyle = { - "drop-shadow": 0, - "inner-shadow": 1, -}; - -export const RawShapeType = { - "frame": 0, - "group": 1, - "bool": 2, - "rect": 3, - "path": 4, - "text": 5, - "circle": 6, - "svg-raw": 7, -}; - -export const RawStrokeStyle = { - "solid": 0, - "dotted": 1, - "dashed": 2, - "mixed": 3, -}; - -export const RawStrokeCap = { - "none": 0, - "line-arrow": 1, - "triangle-arrow": 2, - "square-marker": 3, - "circle-marker": 4, - "diamond-marker": 5, - "round": 6, - "square": 7, -}; - -export const RawFillRule = { - "nonzero": 0, - "evenodd": 1, -}; - -export const RawStrokeLineCap = { - "butt": 0, - "round": 1, - "square": 2, -}; - -export const RawStrokeLineJoin = { - "miter": 0, - "round": 1, - "bevel": 2, -}; - -export const RawTextAlign = { - "left": 0, - "center": 1, - "right": 2, - "justify": 3, -}; - -export const RawTextDirection = { - "ltr": 0, - "rtl": 1, -}; - -export const RawTextDecoration = { - "none": 0, - "underline": 1, - "line-through": 2, - "overline": 3, -}; - -export const RawTextTransform = { - "none": 0, - "uppercase": 1, - "lowercase": 2, - "capitalize": 3, -}; - -export const RawGrowType = { - "fixed": 0, - "auto-width": 1, - "auto-height": 2, -}; - -export const CursorDirection = { - "backward": 0, - "forward": 1, - "line-before": 2, - "line-after": 3, - "line-start": 4, - "line-end": 5, -}; - -export const RawTransformEntryKind = { - "parent": 0, - "child": 1, -}; - diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 94ad28b690..e8e540b8f1 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -78,22 +78,28 @@ (h/call wasm/internal-module "_text_editor_insert_text") (mem/free)))) -(defn text-editor-delete-backward [] - (when wasm/context-initialized? - (h/call wasm/internal-module "_text_editor_delete_backward"))) +(defn text-editor-delete-backward + ([] + (text-editor-delete-backward false)) + ([word-boundary] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_delete_backward" word-boundary)))) -(defn text-editor-delete-forward [] - (when wasm/context-initialized? - (h/call wasm/internal-module "_text_editor_delete_forward"))) +(defn text-editor-delete-forward + ([] + (text-editor-delete-forward false)) + ([word-boundary] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_delete_forward" word-boundary)))) (defn text-editor-insert-paragraph [] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_insert_paragraph"))) (defn text-editor-move-cursor - [direction extend-selection] + [direction word-boundary extend-selection] (when wasm/context-initialized? - (h/call wasm/internal-module "_text_editor_move_cursor" direction (if extend-selection 1 0)))) + (h/call wasm/internal-module "_text_editor_move_cursor" direction word-boundary (if extend-selection 1 0)))) (defn text-editor-select-all [] diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 2766b476c6..65ffbbc19a 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -2,6 +2,7 @@ use crate::shapes::{TextContent, TextPositionWithAffinity}; use crate::uuid::Uuid; +use crate::wasm::text::helpers as text_helpers; use skia_safe::{ textlayout::{Affinity, PositionWithAffinity}, Color, @@ -233,11 +234,14 @@ impl TextEditorState { if offset == chars.len() { offset = offset.saturating_sub(1); - } else if !is_word_char(chars[offset]) && offset > 0 && is_word_char(chars[offset - 1]) { + } else if !text_helpers::is_word_char(chars[offset]) + && offset > 0 + && text_helpers::is_word_char(chars[offset - 1]) + { offset -= 1; } - if !is_word_char(chars[offset]) { + if !text_helpers::is_word_char(chars[offset]) { self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( position.paragraph, position.offset.min(chars.len()), @@ -248,12 +252,12 @@ impl TextEditorState { } let mut start = offset; - while start > 0 && is_word_char(chars[start - 1]) { + while start > 0 && text_helpers::is_word_char(chars[start - 1]) { start -= 1; } let mut end = offset + 1; - while end < chars.len() && is_word_char(chars[end]) { + while end < chars.len() && text_helpers::is_word_char(chars[end]) { end += 1; } diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index c4f6a682d5..3635e0949f 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -11,6 +11,8 @@ use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_m use crate::error::Error; +pub mod helpers; + const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::(); const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::(); diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs new file mode 100644 index 0000000000..88ec07754c --- /dev/null +++ b/render-wasm/src/wasm/text/helpers.rs @@ -0,0 +1,753 @@ +use crate::shapes::{Paragraph, TextContent, TextPositionWithAffinity}; +use crate::state::TextSelection; + +/// Get total character count in a paragraph. +pub fn paragraph_char_count(para: &Paragraph) -> usize { + para.children() + .iter() + .map(|span| span.text.chars().count()) + .sum() +} + +/// Get the text direction of the span at a given offset in a paragraph. +pub fn get_span_text_direction_at_offset( + para: &Paragraph, + char_offset: usize, +) -> skia_safe::textlayout::TextDirection { + if let Some((span_idx, _)) = find_span_at_offset(para, char_offset) { + if let Some(span) = para.children().get(span_idx) { + return span.text_direction; + } + } + // Fallback to paragraph's text direction + para.text_direction() +} + +/// Clamp a cursor position to valid bounds within the text content. +pub fn clamp_cursor( + position: TextPositionWithAffinity, + paragraphs: &[Paragraph], +) -> TextPositionWithAffinity { + if paragraphs.is_empty() { + return TextPositionWithAffinity::new_without_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) +} + +/// Move cursor left by one character. +pub fn move_cursor_backward( + cursor: &TextPositionWithAffinity, + paragraphs: &[Paragraph], + word_boundary: bool, +) -> TextPositionWithAffinity { + if !word_boundary { + if cursor.offset > 0 { + return TextPositionWithAffinity::new_without_affinity( + cursor.paragraph, + cursor.offset - 1, + ); + } + 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 *cursor; + } + + if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() { + return *cursor; + } + + let end_para = cursor.paragraph; + let end_offset = cursor + .offset + .min(paragraph_char_count(¶graphs[end_para])); + + let mut para_idx = end_para; + let mut offset = end_offset; + let mut phase = 0u8; + + loop { + let current = if offset > 0 { + paragraph_text_char_at(¶graphs[para_idx], offset - 1) + } else if para_idx > 0 { + Some('\n') + } else { + None + }; + + let Some(ch) = current else { + break; + }; + + if offset > 0 { + offset -= 1; + } else { + para_idx -= 1; + offset = paragraph_char_count(¶graphs[para_idx]); + } + + if phase == 0 { + if is_word_char(ch) { + phase = 1; + } + continue; + } + + if !is_word_char(ch) { + if offset < paragraph_char_count(¶graphs[para_idx]) { + offset += 1; + } else if para_idx < end_para || offset < end_offset { + para_idx += 1; + offset = 0; + } + break; + } + } + + TextPositionWithAffinity::new_without_affinity(para_idx, offset) +} + +/// Move cursor right by one character. +pub fn move_cursor_forward( + cursor: &TextPositionWithAffinity, + paragraphs: &[Paragraph], + word_boundary: bool, +) -> TextPositionWithAffinity { + if !word_boundary { + let para = ¶graphs[cursor.paragraph]; + let char_count = paragraph_char_count(para); + if cursor.offset < char_count { + return TextPositionWithAffinity::new_without_affinity( + cursor.paragraph, + cursor.offset + 1, + ); + } + if cursor.paragraph < paragraphs.len() - 1 { + return TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); + } + return *cursor; + } + + if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() { + return *cursor; + } + + let mut para_idx = cursor.paragraph; + let mut offset = cursor + .offset + .min(paragraph_char_count(¶graphs[para_idx])); + let mut phase = 0u8; + + loop { + let para = ¶graphs[para_idx]; + let para_len = paragraph_char_count(para); + + let current = if offset < para_len { + paragraph_text_char_at(para, offset) + } else if para_idx < paragraphs.len() - 1 { + Some('\n') + } else { + None + }; + + let Some(ch) = current else { + break; + }; + + if phase == 0 { + if is_word_char(ch) { + phase = 1; + } else if offset < para_len { + offset += 1; + } else { + para_idx += 1; + offset = 0; + } + continue; + } + + if is_word_char(ch) { + if offset < para_len { + offset += 1; + } else { + para_idx += 1; + offset = 0; + } + } else { + break; + } + } + + TextPositionWithAffinity::new_without_affinity(para_idx, offset) +} + +/// Move cursor up by one line. +pub fn move_cursor_up( + cursor: &TextPositionWithAffinity, + paragraphs: &[Paragraph], + _text_content: &TextContent, +) -> TextPositionWithAffinity { + // TODO: Implement proper line-based navigation using line metrics + if cursor.paragraph > 0 { + 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) + } else { + TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) + } +} + +/// Move cursor down by one line. +pub fn move_cursor_down( + cursor: &TextPositionWithAffinity, + paragraphs: &[Paragraph], + _text_content: &TextContent, +) -> TextPositionWithAffinity { + // TODO: Implement proper line-based navigation using line metrics + if cursor.paragraph < paragraphs.len() - 1 { + 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) + } else { + let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); + TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) + } +} + +/// Move cursor to start of current line. +pub fn move_cursor_line_start( + cursor: &TextPositionWithAffinity, + _paragraphs: &[Paragraph], +) -> TextPositionWithAffinity { + // TODO: Implement proper line-start using line metrics + TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) +} + +/// Move cursor to end of current line. +pub fn move_cursor_line_end( + cursor: &TextPositionWithAffinity, + paragraphs: &[Paragraph], +) -> 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) +} + +pub fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +pub fn paragraph_text_char_at(para: &Paragraph, offset: usize) -> Option { + let mut remaining = offset; + for span in para.children() { + let span_len = span.text.chars().count(); + if remaining < span_len { + return span.text.chars().nth(remaining); + } + remaining -= span_len; + } + None +} + +pub fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, usize)> { + let children = para.children(); + let mut accumulated = 0; + for (span_idx, span) in children.iter().enumerate() { + let span_len = span.text.chars().count(); + if char_offset <= accumulated + span_len { + return Some((span_idx, char_offset - accumulated)); + } + accumulated += span_len; + } + if !children.is_empty() { + let last_idx = children.len() - 1; + let last_len = children[last_idx].text.chars().count(); + return Some((last_idx, last_len)); + } + None +} + +/// 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( + 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) = insert_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) = insert_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. Returns the new character offset after insertion. +pub fn insert_text_at_cursor( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, + text: &str, +) -> Option { + 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.chars().count()); + } + + let (span_idx, offset_in_span) = find_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()); + + new_text.insert_str(byte_offset, text); + span.set_text(new_text); + + Some(cursor.offset + text.chars().count()) +} + +/// Delete a range of text specified by a selection. +pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) { + let start = selection.start(); + let end = selection.end(); + + let paragraphs = text_content.paragraphs_mut(); + if start.paragraph >= paragraphs.len() { + return; + } + + if start.paragraph == end.paragraph { + delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset); + } else { + let start_para_len = paragraph_char_count(¶graphs[start.paragraph]); + delete_range_in_paragraph( + &mut paragraphs[start.paragraph], + start.offset, + start_para_len, + ); + + delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset); + + if end.paragraph < paragraphs.len() { + let end_para_children: Vec<_> = + paragraphs[end.paragraph].children_mut().drain(..).collect(); + paragraphs[start.paragraph] + .children_mut() + .extend(end_para_children); + } + + if end.paragraph < paragraphs.len() { + paragraphs.drain((start.paragraph + 1)..=end.paragraph); + } + + let children = paragraphs[start.paragraph].children_mut(); + let has_content = children.iter().any(|span| !span.text.is_empty()); + if has_content { + children.retain(|span| !span.text.is_empty()); + } else if children.len() > 1 { + children.truncate(1); + } + } +} + +/// Delete a range of characters within a single paragraph. +pub fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offset: usize) { + if start_offset >= end_offset { + return; + } + + let mut accumulated = 0; + let mut delete_start_span = None; + let mut delete_end_span = None; + + for (idx, span) in para.children().iter().enumerate() { + let span_len = span.text.chars().count(); + let span_end = accumulated + span_len; + + if delete_start_span.is_none() && start_offset < span_end { + delete_start_span = Some((idx, start_offset - accumulated)); + } + if end_offset <= span_end { + delete_end_span = Some((idx, end_offset - accumulated)); + break; + } + accumulated += span_len; + } + + let Some((start_span_idx, start_in_span)) = delete_start_span else { + return; + }; + let Some((end_span_idx, end_in_span)) = delete_end_span else { + return; + }; + + let children = para.children_mut(); + + if start_span_idx == end_span_idx { + let span = &mut children[start_span_idx]; + let text = span.text.clone(); + let chars: Vec = text.chars().collect(); + + let start_clamped = start_in_span.min(chars.len()); + let end_clamped = end_in_span.min(chars.len()); + + let new_text: String = chars[..start_clamped] + .iter() + .chain(chars[end_clamped..].iter()) + .collect(); + span.set_text(new_text); + } else { + let start_span = &mut children[start_span_idx]; + let text = start_span.text.clone(); + let start_char_count = text.chars().count(); + let start_clamped = start_in_span.min(start_char_count); + let new_text: String = text.chars().take(start_clamped).collect(); + start_span.set_text(new_text); + + let end_span = &mut children[end_span_idx]; + let text = end_span.text.clone(); + let end_char_count = text.chars().count(); + let end_clamped = end_in_span.min(end_char_count); + let new_text: String = text.chars().skip(end_clamped).collect(); + end_span.set_text(new_text); + + if end_span_idx > start_span_idx + 1 { + children.drain((start_span_idx + 1)..end_span_idx); + } + } + + let has_content = children.iter().any(|span| !span.text.is_empty()); + if has_content { + children.retain(|span| !span.text.is_empty()); + } else if !children.is_empty() { + children.truncate(1); + } +} + +/// Delete the character before the cursor. Returns the new cursor position. +pub fn delete_char_before( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, +) -> Option { + if cursor.offset > 0 { + let paragraphs = text_content.paragraphs_mut(); + 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( + cursor.paragraph, + delete_pos, + )) + } else if cursor.paragraph > 0 { + let prev_para_idx = cursor.paragraph - 1; + let paragraphs = text_content.paragraphs_mut(); + let prev_para_len = paragraph_char_count(¶graphs[prev_para_idx]); + + let current_children: Vec<_> = paragraphs[cursor.paragraph] + .children_mut() + .drain(..) + .collect(); + paragraphs[prev_para_idx] + .children_mut() + .extend(current_children); + + paragraphs.remove(cursor.paragraph); + + Some(TextPositionWithAffinity::new_without_affinity( + prev_para_idx, + prev_para_len, + )) + } else { + None + } +} + +/// Delete the word before the cursor. Returns the new cursor position. +pub fn delete_word_before( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, +) -> Option { + let paragraphs = text_content.paragraphs(); + if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() { + return None; + } + + let end_paragraph = cursor.paragraph; + let end_offset = cursor + .offset + .min(paragraph_char_count(¶graphs[end_paragraph])); + + let mut start_paragraph = end_paragraph; + let mut start_offset = end_offset; + + let mut phase = 0u8; + loop { + let current = if start_offset > 0 { + let para = ¶graphs[start_paragraph]; + paragraph_text_char_at(para, start_offset - 1) + } else if start_paragraph > 0 { + Some('\n') + } else { + None + }; + + let Some(ch) = current else { + break; + }; + + if start_offset > 0 { + start_offset -= 1; + } else { + start_paragraph -= 1; + start_offset = paragraph_char_count(¶graphs[start_paragraph]); + } + + if phase == 0 { + if is_word_char(ch) { + phase = 1; + } + continue; + } + + if !is_word_char(ch) { + if start_offset < paragraph_char_count(¶graphs[start_paragraph]) { + start_offset += 1; + } else if start_paragraph < end_paragraph || start_offset < end_offset { + start_paragraph += 1; + start_offset = 0; + } + break; + } + } + + if start_paragraph == end_paragraph && start_offset == end_offset { + return None; + } + + let selection = TextSelection { + anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset), + focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset), + }; + + delete_selection_range(text_content, &selection); + + Some(TextPositionWithAffinity::new_without_affinity( + start_paragraph, + start_offset, + )) +} + +/// Delete the word after the cursor. +pub fn delete_word_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) { + let paragraphs = text_content.paragraphs(); + if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() { + return; + } + + let start_paragraph = cursor.paragraph; + let start_offset = cursor + .offset + .min(paragraph_char_count(¶graphs[start_paragraph])); + + let mut end_paragraph = start_paragraph; + let mut end_offset = start_offset; + + let mut phase = 0u8; + loop { + let para = ¶graphs[end_paragraph]; + let para_len = paragraph_char_count(para); + + let current = if end_offset < para_len { + paragraph_text_char_at(para, end_offset) + } else if end_paragraph < paragraphs.len() - 1 { + Some('\n') + } else { + None + }; + + let Some(ch) = current else { + break; + }; + + if phase == 0 { + if is_word_char(ch) { + phase = 1; + } else if end_offset < para_len { + end_offset += 1; + } else { + end_paragraph += 1; + end_offset = 0; + } + continue; + } + + if is_word_char(ch) { + if end_offset < para_len { + end_offset += 1; + } else { + end_paragraph += 1; + end_offset = 0; + } + } else { + break; + } + } + + if start_paragraph == end_paragraph && start_offset == end_offset { + return; + } + + let selection = TextSelection { + anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset), + focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset), + }; + + delete_selection_range(text_content, &selection); +} + +/// Delete the character after the cursor. +pub fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) { + let paragraphs = text_content.paragraphs_mut(); + if cursor.paragraph >= paragraphs.len() { + return; + } + + let para_len = paragraph_char_count(¶graphs[cursor.paragraph]); + + if cursor.offset < para_len { + let para = &mut paragraphs[cursor.paragraph]; + delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1); + } 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(); + paragraphs[cursor.paragraph] + .children_mut() + .extend(next_children); + + paragraphs.remove(next_para_idx); + } +} + +/// Split a paragraph at the cursor position. Returns true if split was successful. +pub fn split_paragraph_at_cursor( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, +) -> bool { + let paragraphs = text_content.paragraphs_mut(); + if cursor.paragraph >= paragraphs.len() { + return false; + } + + let para = ¶graphs[cursor.paragraph]; + + let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else { + return false; + }; + + let mut new_para_children = Vec::new(); + let children = para.children(); + + let current_span = &children[span_idx]; + let span_text = current_span.text.clone(); + let chars: Vec = span_text.chars().collect(); + + if offset_in_span < chars.len() { + let after_text: String = chars[offset_in_span..].iter().collect(); + let mut new_span = current_span.clone(); + new_span.set_text(after_text); + new_para_children.push(new_span); + } + + for child in children.iter().skip(span_idx + 1) { + new_para_children.push(child.clone()); + } + + if new_para_children.is_empty() { + let mut empty_span = current_span.clone(); + empty_span.set_text(String::new()); + new_para_children.push(empty_span); + } + + let text_align = para.text_align(); + let text_direction = para.text_direction(); + let text_decoration = para.text_decoration(); + let text_transform = para.text_transform(); + let line_height = para.line_height(); + let letter_spacing = para.letter_spacing(); + + let para = &mut paragraphs[cursor.paragraph]; + let children = para.children_mut(); + + children.truncate(span_idx + 1); + + if !children.is_empty() { + let span = &mut children[span_idx]; + let text = span.text.clone(); + let new_text: String = text.chars().take(offset_in_span).collect(); + span.set_text(new_text); + } + + let new_para = crate::shapes::Paragraph::new( + text_align, + text_direction, + text_decoration, + text_transform, + line_height, + letter_spacing, + new_para_children, + ); + + paragraphs.insert(cursor.paragraph + 1, new_para); + + true +} diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 0886340851..25182c5050 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -2,10 +2,11 @@ use macros::{wasm_error, ToJs}; use crate::math::{Matrix, Point, Rect}; use crate::mem; -use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; +use crate::shapes::{Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::state::TextSelection; use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; +use crate::wasm::text::helpers as text_helpers; use crate::{with_state, with_state_mut, STATE}; use skia_safe::{textlayout::TextDirection, Color}; @@ -322,14 +323,16 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { let selection = state.text_editor_state.selection; if selection.is_selection() { - delete_selection_range(text_content, &selection); + text_helpers::delete_selection_range(text_content, &selection); let start = selection.start(); state.text_editor_state.selection.set_caret(start); } let cursor = state.text_editor_state.selection.focus; - if let Some(new_cursor) = insert_text_with_newlines(text_content, &cursor, &text) { + if let Some(new_cursor) = + text_helpers::insert_text_with_newlines(text_content, &cursor, &text) + { state.text_editor_state.selection.set_caret(new_cursor); } @@ -352,7 +355,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { } #[no_mangle] -pub extern "C" fn text_editor_delete_backward() { +pub extern "C" fn text_editor_delete_backward(word_boundary: bool) { with_state_mut!(state, { if !state.text_editor_state.is_active { return; @@ -373,13 +376,18 @@ pub extern "C" fn text_editor_delete_backward() { let selection = state.text_editor_state.selection; if selection.is_selection() { - delete_selection_range(text_content, &selection); + text_helpers::delete_selection_range(text_content, &selection); let start = selection.start(); - let clamped = clamp_cursor(start, text_content.paragraphs()); + let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); state.text_editor_state.selection.set_caret(clamped); + } else if word_boundary { + let cursor = selection.focus; + if let Some(new_cursor) = text_helpers::delete_word_before(text_content, &cursor) { + state.text_editor_state.selection.set_caret(new_cursor); + } } else { let cursor = selection.focus; - if let Some(new_cursor) = delete_char_before(text_content, &cursor) { + if let Some(new_cursor) = text_helpers::delete_char_before(text_content, &cursor) { state.text_editor_state.selection.set_caret(new_cursor); } } @@ -400,7 +408,7 @@ pub extern "C" fn text_editor_delete_backward() { } #[no_mangle] -pub extern "C" fn text_editor_delete_forward() { +pub extern "C" fn text_editor_delete_forward(word_boundary: bool) { with_state_mut!(state, { if !state.text_editor_state.is_active { return; @@ -421,14 +429,19 @@ pub extern "C" fn text_editor_delete_forward() { let selection = state.text_editor_state.selection; if selection.is_selection() { - delete_selection_range(text_content, &selection); + text_helpers::delete_selection_range(text_content, &selection); let start = selection.start(); - let clamped = clamp_cursor(start, text_content.paragraphs()); + let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); + state.text_editor_state.selection.set_caret(clamped); + } else if word_boundary { + let cursor = selection.focus; + text_helpers::delete_word_after(text_content, &cursor); + let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); state.text_editor_state.selection.set_caret(clamped); } else { let cursor = selection.focus; - delete_char_after(text_content, &cursor); - let clamped = clamp_cursor(cursor, text_content.paragraphs()); + text_helpers::delete_char_after(text_content, &cursor); + let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); state.text_editor_state.selection.set_caret(clamped); } @@ -469,14 +482,14 @@ pub extern "C" fn text_editor_insert_paragraph() { let selection = state.text_editor_state.selection; if selection.is_selection() { - delete_selection_range(text_content, &selection); + text_helpers::delete_selection_range(text_content, &selection); let start = selection.start(); state.text_editor_state.selection.set_caret(start); } let cursor = state.text_editor_state.selection.focus; - if split_paragraph_at_cursor(text_content, &cursor) { + if text_helpers::split_paragraph_at_cursor(text_content, &cursor) { let new_cursor = TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); state.text_editor_state.selection.set_caret(new_cursor); @@ -502,7 +515,11 @@ pub extern "C" fn text_editor_insert_paragraph() { // ============================================================================ #[no_mangle] -pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_selection: bool) { +pub extern "C" fn text_editor_move_cursor( + direction: CursorDirection, + word_boundary: bool, + extend_selection: bool, +) { with_state_mut!(state, { if !state.text_editor_state.is_active { return; @@ -529,7 +546,10 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel // Get the text direction of the span at the current cursor position let span_text_direction = if current.paragraph < paragraphs.len() { - get_span_text_direction_at_offset(¶graphs[current.paragraph], current.offset) + text_helpers::get_span_text_direction_at_offset( + ¶graphs[current.paragraph], + current.offset, + ) } else { TextDirection::LTR }; @@ -546,16 +566,22 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel }; let new_cursor = match adjusted_direction { - CursorDirection::Backward => move_cursor_backward(¤t, paragraphs), - CursorDirection::Forward => move_cursor_forward(¤t, paragraphs), + CursorDirection::Backward => { + text_helpers::move_cursor_backward(¤t, paragraphs, word_boundary) + } + CursorDirection::Forward => { + text_helpers::move_cursor_forward(¤t, paragraphs, word_boundary) + } CursorDirection::LineBefore => { - move_cursor_up(¤t, paragraphs, text_content, shape) + text_helpers::move_cursor_up(¤t, paragraphs, text_content) } CursorDirection::LineAfter => { - move_cursor_down(¤t, paragraphs, text_content, shape) + text_helpers::move_cursor_down(¤t, paragraphs, text_content) } - CursorDirection::LineStart => move_cursor_line_start(¤t, paragraphs), - CursorDirection::LineEnd => move_cursor_line_end(¤t, paragraphs), + CursorDirection::LineStart => { + text_helpers::move_cursor_line_start(¤t, paragraphs) + } + CursorDirection::LineEnd => text_helpers::move_cursor_line_end(¤t, paragraphs), }; if extend_selection { @@ -979,485 +1005,3 @@ fn get_selection_rects( rects } - -/// Get total character count in a paragraph. -fn paragraph_char_count(para: &Paragraph) -> usize { - para.children() - .iter() - .map(|span| span.text.chars().count()) - .sum() -} - -/// Clamp a cursor position to valid bounds within the text content. -fn clamp_cursor( - position: TextPositionWithAffinity, - paragraphs: &[Paragraph], -) -> TextPositionWithAffinity { - if paragraphs.is_empty() { - return TextPositionWithAffinity::new_without_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) -} - -/// Move cursor left by one character. -fn move_cursor_backward( - cursor: &TextPositionWithAffinity, - paragraphs: &[Paragraph], -) -> TextPositionWithAffinity { - if cursor.offset > 0 { - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset - 1) - } else if cursor.paragraph > 0 { - let prev_para = cursor.paragraph - 1; - let char_count = paragraph_char_count(¶graphs[prev_para]); - TextPositionWithAffinity::new_without_affinity(prev_para, char_count) - } else { - *cursor - } -} - -/// Move cursor right by one character. -fn move_cursor_forward( - cursor: &TextPositionWithAffinity, - paragraphs: &[Paragraph], -) -> TextPositionWithAffinity { - let para = ¶graphs[cursor.paragraph]; - let char_count = paragraph_char_count(para); - - if cursor.offset < char_count { - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset + 1) - } else if cursor.paragraph < paragraphs.len() - 1 { - TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0) - } else { - *cursor - } -} - -/// Move cursor up by one line. -fn move_cursor_up( - cursor: &TextPositionWithAffinity, - paragraphs: &[Paragraph], - _text_content: &TextContent, - _shape: &Shape, -) -> TextPositionWithAffinity { - // TODO: Implement proper line-based navigation using line metrics - if cursor.paragraph > 0 { - 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) - } else { - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) - } -} - -/// Move cursor down by one line. -fn move_cursor_down( - cursor: &TextPositionWithAffinity, - paragraphs: &[Paragraph], - _text_content: &TextContent, - _shape: &Shape, -) -> TextPositionWithAffinity { - // TODO: Implement proper line-based navigation using line metrics - if cursor.paragraph < paragraphs.len() - 1 { - 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) - } else { - let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) - } -} - -/// Move cursor to start of current line. -fn move_cursor_line_start( - cursor: &TextPositionWithAffinity, - _paragraphs: &[Paragraph], -) -> TextPositionWithAffinity { - // TODO: Implement proper line-start using line metrics - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) -} - -/// Move cursor to end of current line. -fn move_cursor_line_end( - cursor: &TextPositionWithAffinity, - paragraphs: &[Paragraph], -) -> 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) -} - -// ============================================================================ -// HELPERS: Text Modification -// ============================================================================ - -fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, usize)> { - let children = para.children(); - let mut accumulated = 0; - for (span_idx, span) in children.iter().enumerate() { - let span_len = span.text.chars().count(); - if char_offset <= accumulated + span_len { - return Some((span_idx, char_offset - accumulated)); - } - accumulated += span_len; - } - if !children.is_empty() { - let last_idx = children.len() - 1; - let last_len = children[last_idx].text.chars().count(); - return Some((last_idx, last_len)); - } - None -} - -/// Insert text at a cursor position, splitting on newlines into multiple paragraphs. -/// Returns the final cursor position after insertion. -fn insert_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) = insert_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) = insert_text_at_cursor(text_content, ¤t_cursor, line) { - current_cursor = TextPositionWithAffinity::new_without_affinity( - current_cursor.paragraph, - new_offset, - ); - } - } - - Some(current_cursor) -} - -/// Get the text direction of the span at a given offset in a paragraph. -fn get_span_text_direction_at_offset( - para: &Paragraph, - char_offset: usize, -) -> skia_safe::textlayout::TextDirection { - if let Some((span_idx, _)) = find_span_at_offset(para, char_offset) { - if let Some(span) = para.children().get(span_idx) { - return span.text_direction; - } - } - // Fallback to paragraph's text direction - para.text_direction() -} - -/// Insert text at a cursor position. Returns the new character offset after insertion. -fn insert_text_at_cursor( - text_content: &mut TextContent, - cursor: &TextPositionWithAffinity, - text: &str, -) -> Option { - 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.chars().count()); - } - - let (span_idx, offset_in_span) = find_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()); - - new_text.insert_str(byte_offset, text); - span.set_text(new_text); - - Some(cursor.offset + text.chars().count()) -} - -/// Delete a range of text specified by a selection. -fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) { - let start = selection.start(); - let end = selection.end(); - - let paragraphs = text_content.paragraphs_mut(); - if start.paragraph >= paragraphs.len() { - return; - } - - if start.paragraph == end.paragraph { - delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset); - } else { - let start_para_len = paragraph_char_count(¶graphs[start.paragraph]); - delete_range_in_paragraph( - &mut paragraphs[start.paragraph], - start.offset, - start_para_len, - ); - - delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset); - - if end.paragraph < paragraphs.len() { - let end_para_children: Vec<_> = - paragraphs[end.paragraph].children_mut().drain(..).collect(); - paragraphs[start.paragraph] - .children_mut() - .extend(end_para_children); - } - - if end.paragraph < paragraphs.len() { - paragraphs.drain((start.paragraph + 1)..=end.paragraph); - } - - let children = paragraphs[start.paragraph].children_mut(); - let has_content = children.iter().any(|span| !span.text.is_empty()); - if has_content { - children.retain(|span| !span.text.is_empty()); - } else if children.len() > 1 { - children.truncate(1); - } - } -} - -/// Delete a range of characters within a single paragraph. -fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offset: usize) { - if start_offset >= end_offset { - return; - } - - let mut accumulated = 0; - let mut delete_start_span = None; - let mut delete_end_span = None; - - for (idx, span) in para.children().iter().enumerate() { - let span_len = span.text.chars().count(); - let span_end = accumulated + span_len; - - if delete_start_span.is_none() && start_offset < span_end { - delete_start_span = Some((idx, start_offset - accumulated)); - } - if end_offset <= span_end { - delete_end_span = Some((idx, end_offset - accumulated)); - break; - } - accumulated += span_len; - } - - let Some((start_span_idx, start_in_span)) = delete_start_span else { - return; - }; - let Some((end_span_idx, end_in_span)) = delete_end_span else { - return; - }; - - let children = para.children_mut(); - - if start_span_idx == end_span_idx { - let span = &mut children[start_span_idx]; - let text = span.text.clone(); - let chars: Vec = text.chars().collect(); - - let start_clamped = start_in_span.min(chars.len()); - let end_clamped = end_in_span.min(chars.len()); - - let new_text: String = chars[..start_clamped] - .iter() - .chain(chars[end_clamped..].iter()) - .collect(); - span.set_text(new_text); - } else { - let start_span = &mut children[start_span_idx]; - let text = start_span.text.clone(); - let start_char_count = text.chars().count(); - let start_clamped = start_in_span.min(start_char_count); - let new_text: String = text.chars().take(start_clamped).collect(); - start_span.set_text(new_text); - - let end_span = &mut children[end_span_idx]; - let text = end_span.text.clone(); - let end_char_count = text.chars().count(); - let end_clamped = end_in_span.min(end_char_count); - let new_text: String = text.chars().skip(end_clamped).collect(); - end_span.set_text(new_text); - - if end_span_idx > start_span_idx + 1 { - children.drain((start_span_idx + 1)..end_span_idx); - } - } - - let has_content = children.iter().any(|span| !span.text.is_empty()); - if has_content { - children.retain(|span| !span.text.is_empty()); - } else if !children.is_empty() { - children.truncate(1); - } -} - -/// Delete the character before the cursor. Returns the new cursor position. -fn delete_char_before( - text_content: &mut TextContent, - cursor: &TextPositionWithAffinity, -) -> Option { - if cursor.offset > 0 { - let paragraphs = text_content.paragraphs_mut(); - 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( - cursor.paragraph, - delete_pos, - )) - } else if cursor.paragraph > 0 { - let prev_para_idx = cursor.paragraph - 1; - let paragraphs = text_content.paragraphs_mut(); - let prev_para_len = paragraph_char_count(¶graphs[prev_para_idx]); - - let current_children: Vec<_> = paragraphs[cursor.paragraph] - .children_mut() - .drain(..) - .collect(); - paragraphs[prev_para_idx] - .children_mut() - .extend(current_children); - - paragraphs.remove(cursor.paragraph); - - Some(TextPositionWithAffinity::new_without_affinity( - prev_para_idx, - prev_para_len, - )) - } else { - None - } -} - -/// Delete the character after the cursor. -fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) { - let paragraphs = text_content.paragraphs_mut(); - if cursor.paragraph >= paragraphs.len() { - return; - } - - let para_len = paragraph_char_count(¶graphs[cursor.paragraph]); - - if cursor.offset < para_len { - let para = &mut paragraphs[cursor.paragraph]; - delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1); - } 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(); - paragraphs[cursor.paragraph] - .children_mut() - .extend(next_children); - - paragraphs.remove(next_para_idx); - } -} - -/// Split a paragraph at the cursor position. Returns true if split was successful. -fn split_paragraph_at_cursor( - text_content: &mut TextContent, - cursor: &TextPositionWithAffinity, -) -> bool { - let paragraphs = text_content.paragraphs_mut(); - if cursor.paragraph >= paragraphs.len() { - return false; - } - - let para = ¶graphs[cursor.paragraph]; - - let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else { - return false; - }; - - let mut new_para_children = Vec::new(); - let children = para.children(); - - let current_span = &children[span_idx]; - let span_text = current_span.text.clone(); - let chars: Vec = span_text.chars().collect(); - - if offset_in_span < chars.len() { - let after_text: String = chars[offset_in_span..].iter().collect(); - let mut new_span = current_span.clone(); - new_span.set_text(after_text); - new_para_children.push(new_span); - } - - for child in children.iter().skip(span_idx + 1) { - new_para_children.push(child.clone()); - } - - if new_para_children.is_empty() { - let mut empty_span = current_span.clone(); - empty_span.set_text(String::new()); - new_para_children.push(empty_span); - } - - let text_align = para.text_align(); - let text_direction = para.text_direction(); - let text_decoration = para.text_decoration(); - let text_transform = para.text_transform(); - let line_height = para.line_height(); - let letter_spacing = para.letter_spacing(); - - let para = &mut paragraphs[cursor.paragraph]; - let children = para.children_mut(); - - children.truncate(span_idx + 1); - - if !children.is_empty() { - let span = &mut children[span_idx]; - let text = span.text.clone(); - let new_text: String = text.chars().take(offset_in_span).collect(); - span.set_text(new_text); - } - - let new_para = crate::shapes::Paragraph::new( - text_align, - text_direction, - text_decoration, - text_transform, - line_height, - letter_spacing, - new_para_children, - ); - - paragraphs.insert(cursor.paragraph + 1, new_para); - - true -}