mirror of
https://github.com/penpot/penpot.git
synced 2026-03-18 16:33:49 +00:00
Merge pull request #8587 from penpot/azazeln28-feat-word-boundary-cursor-navigation
🎉 Feat word boundary cursor navigation
This commit is contained in:
20
.github/workflows/tests.yml
vendored
20
.github/workflows/tests.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
[]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<RawTextSpan>();
|
||||
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
|
||||
|
||||
|
||||
753
render-wasm/src/wasm/text/helpers.rs
Normal file
753
render-wasm/src/wasm/text/helpers.rs
Normal file
@@ -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<char> {
|
||||
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<TextPositionWithAffinity> {
|
||||
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<usize> {
|
||||
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<char> = 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<TextPositionWithAffinity> {
|
||||
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<TextPositionWithAffinity> {
|
||||
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<char> = 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
|
||||
}
|
||||
@@ -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<TextPositionWithAffinity> {
|
||||
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<usize> {
|
||||
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<char> = 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<TextPositionWithAffinity> {
|
||||
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<char> = 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user