mirror of
https://github.com/penpot/penpot.git
synced 2026-11-01 15:40:41 +00:00
* 🐛 Keep text gradients in place when pasting at the caret Text gradients are stored as fractions of the whole text box. Paste copied those fractions unchanged, so in a box of another size the handles and colours moved away from the pasted text. Copy from the v3 editor now records where each span with a gradient or image fill starts in its text box, and the box size, in the clipboard payload. A new WASM export, text_editor_get_range_origin, gives the start of any text range. Paste at the caret finds where those spans start in the target and rewrites their gradients so the handles keep the same pixel offset from the span. Auto-sized texts use the box size they get when the editor closes. AI-assisted-by: claude-opus-5-5 * 🐛 Rebuild text gradient shaders when the text box resizes Text gradient and image shaders are built into the cached Skia paragraphs against the text box size. A resize with no content change reused those paragraphs, so the colours stayed matched to the old box until a reload. The text now remembers the box size its shaders were built for and lays itself out again when that size changes and a span has a gradient or image fill. Text with only solid fills keeps its cache. AI-assisted-by: claude-opus-5-5 * 🐛 Keep text gradients in place when pasting on the canvas A text selection pasted on the canvas becomes a new text whose box differs from the source, so its gradients moved like they did for caret paste. After adding the new text, lay it out, find where its gradient spans start and move their gradients to keep the same pixel offset from the span, in the same undo step. The range origin export now takes a shape id, so it works on texts that are not being edited. AI-assisted-by: claude-opus-5-5 * 🐛 Keep text gradients in place when pasting copied texts at the caret Text shapes copied whole, rather than as an editor selection, reached the caret paste without any geometry, so their gradients still moved away from the pasted text. A shape copy now records where the gradient and image spans of each selected text start, and the box size, by shape id. Paste joins them in the order of the joined paragraphs, with no origin for texts copied without one. Copied texts each have their own box, so every span origin now carries its own box size instead of one box for the whole payload. AI-assisted-by: claude-opus-5-5
1089 lines
36 KiB
Rust
1089 lines
36 KiB
Rust
use macros::{wasm_error, ToJs};
|
|
|
|
use crate::globals::{get_render_state, get_text_editor_state};
|
|
use crate::math::{Matrix, Point};
|
|
use crate::mem;
|
|
use crate::render::text_editor::range_origin;
|
|
use crate::shapes::{TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
|
|
use crate::state::{State, TextEditorEvent, TextEditorState};
|
|
use crate::utils::uuid_from_u32_quartet;
|
|
use crate::utils::uuid_to_u32_quartet;
|
|
use crate::uuid::Uuid;
|
|
use crate::wasm::fills::RawFillData;
|
|
use crate::wasm::text::{
|
|
helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform,
|
|
};
|
|
use crate::with_state;
|
|
use skia_safe::Color;
|
|
|
|
#[derive(PartialEq, ToJs)]
|
|
#[repr(u8)]
|
|
#[allow(dead_code)]
|
|
pub enum CursorDirection {
|
|
Backward = 0,
|
|
Forward = 1,
|
|
LineBefore = 2,
|
|
LineAfter = 3,
|
|
LineStart = 4,
|
|
LineEnd = 5,
|
|
}
|
|
|
|
// ============================================================================
|
|
// STATE MANAGEMENT
|
|
// ============================================================================
|
|
|
|
/// Apply the editor theme. When `invert` is true the caret is painted with a
|
|
/// Difference blend mode (pass white as `cursor_color` to always show the
|
|
/// inverted color of the background); otherwise it is painted with the given
|
|
/// solid `cursor_color`.
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_apply_theme(selection_color: u32, cursor_color: u32, invert: bool) {
|
|
// NOTE: In the future could be interesting to fill al this data from
|
|
// a structure pointer.
|
|
get_text_editor_state().theme.selection_color = Color::new(selection_color);
|
|
get_text_editor_state().theme.cursor_color = Color::new(cursor_color);
|
|
get_text_editor_state().theme.cursor_invert = invert;
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_focus(a: u32, b: u32, c: u32, d: u32) -> bool {
|
|
with_state!(state, {
|
|
let shape_id = uuid_from_u32_quartet(a, b, c, d);
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return false;
|
|
};
|
|
|
|
if !matches!(shape.shape_type, Type::Text(_)) {
|
|
return false;
|
|
}
|
|
|
|
get_text_editor_state().focus(shape_id);
|
|
true
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_blur() -> bool {
|
|
if !get_text_editor_state().has_focus {
|
|
return false;
|
|
}
|
|
get_text_editor_state().blur();
|
|
true
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_dispose() -> bool {
|
|
get_text_editor_state().dispose();
|
|
true
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_has_selection() -> bool {
|
|
get_text_editor_state().selection.is_selection()
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_has_focus() -> bool {
|
|
get_text_editor_state().has_focus
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_has_focus_with_id(a: u32, b: u32, c: u32, d: u32) -> bool {
|
|
let shape_id = uuid_from_u32_quartet(a, b, c, d);
|
|
let Some(active_shape_id) = get_text_editor_state().active_shape_id else {
|
|
return false;
|
|
};
|
|
get_text_editor_state().has_focus && active_shape_id == shape_id
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) {
|
|
if let Some(shape_id) = get_text_editor_state().active_shape_id {
|
|
let (a, b, c, d) = uuid_to_u32_quartet(&shape_id);
|
|
unsafe {
|
|
*buffer_ptr = a;
|
|
*buffer_ptr.add(1) = b;
|
|
*buffer_ptr.add(2) = c;
|
|
*buffer_ptr.add(3) = d;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_select_all() -> bool {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return false;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return false;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return false;
|
|
};
|
|
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return false;
|
|
};
|
|
get_text_editor_state().select_all(text_content)
|
|
})
|
|
}
|
|
|
|
fn with_active_text_at_point<F>(x: f32, y: f32, apply: F)
|
|
where
|
|
F: FnOnce(&mut TextEditorState, &TextContent, &TextPositionWithAffinity),
|
|
{
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
let point = Point::new(x, y);
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
|
apply(get_text_editor_state(), text_content, &position);
|
|
}
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) {
|
|
with_active_text_at_point(x, y, |editor, text_content, position| {
|
|
editor.select_word_boundary(text_content, position)
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_select_paragraph(x: f32, y: f32) {
|
|
// A drag that produced a range must survive the trailing click; a jitter
|
|
// that left the caret collapsed must not suppress the paragraph select.
|
|
let editor = get_text_editor_state();
|
|
if editor.is_click_event_skipped && editor.selection.is_selection() {
|
|
editor.is_click_event_skipped = false;
|
|
return;
|
|
}
|
|
|
|
with_active_text_at_point(x, y, |editor, text_content, position| {
|
|
editor.select_paragraph(text_content, position)
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_poll_event() -> u8 {
|
|
get_text_editor_state().poll_event() as u8
|
|
}
|
|
|
|
// ============================================================================
|
|
// SELECTION MANAGEMENT
|
|
// ============================================================================
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
let point = Point::new(x, y);
|
|
get_text_editor_state().start_pointer_selection();
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
|
get_text_editor_state().place_caret_from_position(text_content, &position);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Like `text_editor_pointer_down`, but keeps the current anchor and moves the
|
|
/// focus to the pointer instead of collapsing the caret there (Shift+click).
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_pointer_down_extend(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
let point = Point::new(x, y);
|
|
get_text_editor_state().start_pointer_selection();
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
|
get_text_editor_state().extend_selection_from_position(&position);
|
|
// The click after pointerup would collapse the caret and drop the
|
|
// selection we just extended.
|
|
get_text_editor_state().is_click_event_skipped = true;
|
|
get_text_editor_state().update_styles(text_content);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let point = Point::new(x, y);
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
if !get_text_editor_state().is_pointer_selection_active {
|
|
return;
|
|
}
|
|
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
|
get_text_editor_state().extend_selection_from_position(&position);
|
|
// We need this flag to prevent handling the click behavior
|
|
// just after a pointerup event.
|
|
get_text_editor_state().is_click_event_skipped = true;
|
|
get_text_editor_state().update_styles(text_content);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
let point = Point::new(x, y);
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
if !get_text_editor_state().is_pointer_selection_active {
|
|
return;
|
|
}
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
|
get_text_editor_state().extend_selection_from_position(&position);
|
|
get_text_editor_state().update_styles(text_content);
|
|
}
|
|
get_text_editor_state().stop_pointer_selection();
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
// We need this flag to prevent handling the click behavior
|
|
// just after a pointerup event.
|
|
if get_text_editor_state().is_click_event_skipped {
|
|
get_text_editor_state().is_click_event_skipped = false;
|
|
return;
|
|
}
|
|
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let point = Point::new(x, y);
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(&point) {
|
|
get_text_editor_state().place_caret_from_position(text_content, &position);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let view_matrix: Matrix = get_render_state().viewbox.get_matrix();
|
|
let point = Point::new(x, y);
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
let shape_matrix = shape.get_matrix();
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
if let Some(position) =
|
|
text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix)
|
|
{
|
|
get_text_editor_state().place_caret_from_position(text_content, &position);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEXT OPERATIONS
|
|
// ============================================================================
|
|
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
pub extern "C" fn text_editor_composition_start() -> Result<()> {
|
|
if !get_text_editor_state().has_focus {
|
|
return Ok(());
|
|
}
|
|
get_text_editor_state().composition.start();
|
|
Ok(())
|
|
}
|
|
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
pub extern "C" fn text_editor_composition_end() -> Result<()> {
|
|
let bytes = crate::mem::bytes_or_empty();
|
|
let text = match String::from_utf8(bytes) {
|
|
Ok(text) => text,
|
|
Err(_) => return Ok(()),
|
|
};
|
|
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return Ok(());
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
|
return Ok(());
|
|
};
|
|
|
|
let Type::Text(text_content) = &mut shape.shape_type else {
|
|
return Ok(());
|
|
};
|
|
|
|
get_text_editor_state().composition.update(&text);
|
|
|
|
let selection = get_text_editor_state()
|
|
.composition
|
|
.get_selection(&get_text_editor_state().selection);
|
|
text_helpers::delete_selection_range(text_content, &selection);
|
|
|
|
let cursor = get_text_editor_state().selection.focus;
|
|
if let Some(new_cursor) =
|
|
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
|
|
{
|
|
get_text_editor_state().selection.set_caret(new_cursor);
|
|
}
|
|
|
|
text_content.layout.clear();
|
|
|
|
get_text_editor_state().reset_blink();
|
|
get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged);
|
|
get_text_editor_state().push_event(crate::state::TextEditorEvent::NeedsLayout);
|
|
|
|
get_render_state().mark_touched(shape_id);
|
|
|
|
get_text_editor_state().composition.end();
|
|
});
|
|
|
|
crate::mem::free_bytes()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
pub extern "C" fn text_editor_composition_update() -> Result<()> {
|
|
let bytes = crate::mem::bytes_or_empty();
|
|
let text = match String::from_utf8(bytes) {
|
|
Ok(text) => text,
|
|
Err(_) => return Ok(()),
|
|
};
|
|
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return Ok(());
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
|
return Ok(());
|
|
};
|
|
|
|
let Type::Text(text_content) = &mut shape.shape_type else {
|
|
return Ok(());
|
|
};
|
|
|
|
get_text_editor_state().composition.update(&text);
|
|
|
|
let selection = get_text_editor_state()
|
|
.composition
|
|
.get_selection(&get_text_editor_state().selection);
|
|
text_helpers::delete_selection_range(text_content, &selection);
|
|
|
|
let cursor = get_text_editor_state().selection.focus;
|
|
text_helpers::insert_text_with_newlines(text_content, &cursor, &text);
|
|
|
|
text_content.layout.clear();
|
|
|
|
get_text_editor_state().reset_blink();
|
|
get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged);
|
|
get_text_editor_state().push_event(crate::state::TextEditorEvent::NeedsLayout);
|
|
|
|
get_render_state().mark_touched(shape_id);
|
|
});
|
|
|
|
crate::mem::free_bytes()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
pub extern "C" fn text_editor_toggle_overtype_mode() -> Result<()> {
|
|
get_text_editor_state().toggle_overtype_mode();
|
|
Ok(())
|
|
}
|
|
|
|
// FIXME: Review if all the return Ok(()) should be Err instead.
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
pub extern "C" fn text_editor_insert_text() -> Result<()> {
|
|
let bytes = crate::mem::bytes();
|
|
let text = match String::from_utf8(bytes) {
|
|
Ok(text) => text,
|
|
Err(_) => return Ok(()),
|
|
};
|
|
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return Ok(());
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
|
return Ok(());
|
|
};
|
|
|
|
let Type::Text(text_content) = &mut shape.shape_type else {
|
|
return Ok(());
|
|
};
|
|
|
|
let selection = get_text_editor_state().selection;
|
|
|
|
if selection.is_selection() {
|
|
text_helpers::delete_selection_range(text_content, &selection);
|
|
let start = selection.start();
|
|
get_text_editor_state().selection.set_caret(start);
|
|
}
|
|
|
|
let cursor = get_text_editor_state().selection.focus;
|
|
if !get_text_editor_state().is_overtype_mode {
|
|
if let Some(new_cursor) =
|
|
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
|
|
{
|
|
get_text_editor_state().selection.set_caret(new_cursor);
|
|
}
|
|
} else if let Some(new_cursor) =
|
|
text_helpers::replace_text_with_newlines(text_content, &cursor, &text)
|
|
{
|
|
get_text_editor_state().selection.set_caret(new_cursor);
|
|
}
|
|
|
|
text_content.layout.clear();
|
|
|
|
get_text_editor_state().reset_blink();
|
|
get_text_editor_state().push_event(TextEditorEvent::ContentChanged);
|
|
get_text_editor_state().push_event(TextEditorEvent::NeedsLayout);
|
|
|
|
get_render_state().mark_touched(shape_id);
|
|
});
|
|
|
|
crate::mem::free_bytes()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_delete_backward(word_boundary: bool) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
let Type::Text(text_content) = &mut shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
get_text_editor_state().delete_backward(text_content, word_boundary);
|
|
get_render_state().mark_touched(shape_id);
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_delete_forward(word_boundary: bool) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
let Type::Text(text_content) = &mut shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
get_text_editor_state().delete_forward(text_content, word_boundary);
|
|
get_render_state().mark_touched(shape_id);
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_insert_paragraph() {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
let Type::Text(text_content) = &mut shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
get_text_editor_state().insert_paragraph(text_content);
|
|
get_render_state().mark_touched(shape_id);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// NAVIGATION
|
|
// ============================================================================
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_move_cursor(
|
|
direction: CursorDirection,
|
|
word_boundary: bool,
|
|
extend_selection: bool,
|
|
) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
get_text_editor_state().move_cursor(
|
|
text_content,
|
|
direction,
|
|
word_boundary,
|
|
extend_selection,
|
|
);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// RENDERING & EXPORT
|
|
// ============================================================================
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return std::ptr::null_mut();
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let Type::Text(_text_content) = &shape.shape_type else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let styles = &get_text_editor_state().current_styles;
|
|
|
|
let vertical_align = match styles.vertical_align {
|
|
VerticalAlign::Top => 0_u32,
|
|
VerticalAlign::Center => 1_u32,
|
|
VerticalAlign::Bottom => 2_u32,
|
|
};
|
|
|
|
let text_align = styles
|
|
.text_align
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| match value {
|
|
TextAlign::Left => RawTextAlign::Left as u32,
|
|
TextAlign::Start => RawTextAlign::Left as u32,
|
|
TextAlign::Center => RawTextAlign::Center as u32,
|
|
TextAlign::Right => RawTextAlign::Right as u32,
|
|
TextAlign::End => RawTextAlign::Right as u32,
|
|
TextAlign::Justify => RawTextAlign::Justify as u32,
|
|
})
|
|
.unwrap_or(0);
|
|
|
|
let text_direction = styles
|
|
.text_direction
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| match value {
|
|
skia_safe::textlayout::TextDirection::LTR => RawTextDirection::Ltr as u32,
|
|
skia_safe::textlayout::TextDirection::RTL => RawTextDirection::Rtl as u32,
|
|
})
|
|
.unwrap_or(0);
|
|
|
|
let text_decoration = styles
|
|
.text_decoration
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| {
|
|
if *value == skia_safe::textlayout::TextDecoration::UNDERLINE {
|
|
RawTextDecoration::Underline as u32
|
|
} else if *value == skia_safe::textlayout::TextDecoration::LINE_THROUGH {
|
|
RawTextDecoration::LineThrough as u32
|
|
} else if *value == skia_safe::textlayout::TextDecoration::OVERLINE {
|
|
RawTextDecoration::Overline as u32
|
|
} else {
|
|
RawTextDecoration::None as u32
|
|
}
|
|
})
|
|
.unwrap_or(RawTextDecoration::None as u32);
|
|
|
|
let text_transform = styles
|
|
.text_transform
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| match value {
|
|
crate::shapes::TextTransform::Uppercase => RawTextTransform::Uppercase as u32,
|
|
crate::shapes::TextTransform::Lowercase => RawTextTransform::Lowercase as u32,
|
|
crate::shapes::TextTransform::Capitalize => RawTextTransform::Capitalize as u32,
|
|
})
|
|
.unwrap_or(RawTextTransform::None as u32);
|
|
|
|
let font_family_id = styles
|
|
.font_family_id
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| {
|
|
let (a, b, c, d) = uuid_to_u32_quartet(value);
|
|
[a, b, c, d]
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let font_style = styles
|
|
.font_style
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| *value as u32)
|
|
.unwrap_or_default();
|
|
|
|
let font_size = styles.font_size.value().unwrap_or(0.0);
|
|
let font_weight = styles.font_weight.value().unwrap_or(0);
|
|
let line_height = styles.line_height.value().unwrap_or(0.0);
|
|
let letter_spacing = styles.letter_spacing.value().unwrap_or(0.0);
|
|
|
|
let mut font_variant_id = [0_u32; 4];
|
|
if let Some(value) = styles.font_variant_id.value().as_ref() {
|
|
let (a, b, c, d) = uuid_to_u32_quartet(value);
|
|
font_variant_id = [a, b, c, d];
|
|
}
|
|
|
|
let mut fill_bytes = Vec::new();
|
|
let fill_multiple = styles.fills_are_multiple;
|
|
let mut fill_count: u32 = 0;
|
|
for fill in &styles.fills {
|
|
if let Ok(raw_fill) = RawFillData::try_from(fill) {
|
|
fill_bytes
|
|
.extend_from_slice(&<[u8; std::mem::size_of::<RawFillData>()]>::from(raw_fill));
|
|
fill_count += 1;
|
|
}
|
|
}
|
|
|
|
// Layout: 56-byte fixed header + fixed values + serialized fills.
|
|
let mut bytes = Vec::with_capacity(132 + fill_bytes.len());
|
|
|
|
// Header data (multiple-states) // offset // index
|
|
bytes.extend_from_slice(&vertical_align.to_le_bytes()); // 0 // 0
|
|
bytes.extend_from_slice(&(*styles.text_align.state() as u32).to_le_bytes()); // 4 // 1
|
|
bytes.extend_from_slice(&(*styles.text_direction.state() as u32).to_le_bytes()); // 8 // 2
|
|
bytes.extend_from_slice(&(*styles.text_decoration.state() as u32).to_le_bytes()); // 12 // 3
|
|
bytes.extend_from_slice(&(*styles.text_transform.state() as u32).to_le_bytes()); // 16 // 4
|
|
bytes.extend_from_slice(&(*styles.font_family_id.state() as u32).to_le_bytes()); // 20 // 5
|
|
bytes.extend_from_slice(&(*styles.font_size.state() as u32).to_le_bytes()); // 24 // 6
|
|
bytes.extend_from_slice(&(*styles.font_weight.state() as u32).to_le_bytes()); // 28 // 7
|
|
bytes.extend_from_slice(&(*styles.font_variant_id.state() as u32).to_le_bytes()); // 32 // 8
|
|
bytes.extend_from_slice(&(*styles.line_height.state() as u32).to_le_bytes()); // 36 // 9
|
|
bytes.extend_from_slice(&(*styles.letter_spacing.state() as u32).to_le_bytes()); // 40 // 10
|
|
bytes.extend_from_slice(&(*styles.font_style.state() as u32).to_le_bytes()); // 44 // 11
|
|
bytes.extend_from_slice(&fill_count.to_le_bytes()); // 48 // 12
|
|
bytes.extend_from_slice(&(fill_multiple as u32).to_le_bytes()); // 52 // 13
|
|
|
|
// Value section.
|
|
bytes.extend_from_slice(&text_align.to_le_bytes()); // 56 // 14
|
|
bytes.extend_from_slice(&text_direction.to_le_bytes()); // 60 // 15
|
|
bytes.extend_from_slice(&text_decoration.to_le_bytes()); // 64 // 16
|
|
bytes.extend_from_slice(&text_transform.to_le_bytes()); // 68 // 17
|
|
bytes.extend_from_slice(&font_family_id[0].to_le_bytes()); // 72 // 18
|
|
bytes.extend_from_slice(&font_family_id[1].to_le_bytes()); // 76 // 19
|
|
bytes.extend_from_slice(&font_family_id[2].to_le_bytes()); // 80 // 20
|
|
bytes.extend_from_slice(&font_family_id[3].to_le_bytes()); // 84 // 21
|
|
bytes.extend_from_slice(&font_style.to_le_bytes()); // 88 // 22
|
|
bytes.extend_from_slice(&font_size.to_le_bytes()); // 92 // 23
|
|
bytes.extend_from_slice(&font_weight.to_le_bytes()); // 96 // 24
|
|
bytes.extend_from_slice(&font_variant_id[0].to_le_bytes()); // 100 // 25
|
|
bytes.extend_from_slice(&font_variant_id[1].to_le_bytes()); // 104 // 26
|
|
bytes.extend_from_slice(&font_variant_id[2].to_le_bytes()); // 108 // 27
|
|
bytes.extend_from_slice(&font_variant_id[3].to_le_bytes()); // 112 // 28
|
|
bytes.extend_from_slice(&line_height.to_le_bytes()); // 116 // 29
|
|
bytes.extend_from_slice(&letter_spacing.to_le_bytes()); // 120 // 30
|
|
bytes.extend_from_slice(&fill_bytes); // 124
|
|
|
|
mem::write_bytes(bytes)
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) {
|
|
get_text_editor_state().update_blink(timestamp_ms);
|
|
}
|
|
|
|
/// Refresh a text shape's layout if the editor marked it dirty, so the
|
|
/// caret/selection overlay is measured against up-to-date glyph geometry.
|
|
fn update_text_layout_if_needed(state: &mut State, shape_id: Uuid) {
|
|
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
let selrect = shape.selrect();
|
|
|
|
let Type::Text(text_content) = &mut shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
if text_content.needs_update_layout() {
|
|
text_content.update_layout(selrect);
|
|
}
|
|
}
|
|
|
|
/// Repaint the caret/selection over the last fully rendered frame.
|
|
///
|
|
/// Re-composes Target from the Backbuffer (which still holds the last complete
|
|
/// render); the compose step draws the editor overlay itself.
|
|
///
|
|
/// This exists because the caret blink must erase the previous caret, which
|
|
/// means restoring the pixels underneath it. Doing that via `render_from_cache`
|
|
/// rebuilds the frame from the document atlas, and that atlas is capped at
|
|
/// scale <= 1.0 — on a zoomed-in view it gets blitted heavily upscaled, so the
|
|
/// blink alternates between the crisp render and a softer approximation, which
|
|
/// reads as a flash. Reusing the Backbuffer is pixel-identical at any zoom.
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_render_caret() {
|
|
with_state!(state, {
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
update_text_layout_if_needed(state, shape_id);
|
|
get_render_state().present_frame(&state.shapes);
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_export_content() -> *mut u8 {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return std::ptr::null_mut();
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let Type::Text(text_content) = &mut shape.shape_type else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let mut json_parts: Vec<String> = Vec::new();
|
|
for para in text_content.paragraphs() {
|
|
let mut span_parts: Vec<String> = Vec::new();
|
|
for span in para.children() {
|
|
let escaped_text = span
|
|
.text
|
|
.replace('\\', "\\\\")
|
|
.replace('"', "\\\"")
|
|
.replace('\n', "\\n")
|
|
.replace('\r', "\\r")
|
|
.replace('\t', "\\t");
|
|
span_parts.push(format!(
|
|
"{{\"p\":{},\"s\":{},\"t\":\"{}\"}}",
|
|
span.paragraph_position, span.span_position, escaped_text
|
|
));
|
|
}
|
|
json_parts.push(format!("[{}]", span_parts.join(",")));
|
|
}
|
|
let json = format!("[{}]", json_parts.join(","));
|
|
|
|
// The host rebuilds its content tree out of this JSON, so the current
|
|
// positions are what the next call has to report against.
|
|
text_content.reset_span_positions();
|
|
|
|
let mut bytes = json.into_bytes();
|
|
bytes.push(0);
|
|
crate::mem::write_bytes(bytes)
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_export_selection() -> *mut u8 {
|
|
use std::ptr;
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return ptr::null_mut();
|
|
}
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return ptr::null_mut();
|
|
};
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return ptr::null_mut();
|
|
};
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return ptr::null_mut();
|
|
};
|
|
let selection = &get_text_editor_state().selection;
|
|
let start = selection.start();
|
|
let end = selection.end();
|
|
let paragraphs = text_content.paragraphs();
|
|
let mut result = String::new();
|
|
let end_paragraph = end.paragraph.min(paragraphs.len().saturating_sub(1)) + 1;
|
|
for (para_idx, _) in paragraphs
|
|
.iter()
|
|
.enumerate()
|
|
.take(end_paragraph)
|
|
.skip(start.paragraph)
|
|
{
|
|
let para = ¶graphs[para_idx];
|
|
let mut para_text = String::new();
|
|
let para_char_count: usize = para
|
|
.children()
|
|
.iter()
|
|
.map(|span| span.text.chars().count())
|
|
.sum();
|
|
let range_start = if para_idx == start.paragraph {
|
|
start.offset
|
|
} else {
|
|
0
|
|
};
|
|
let range_end = if para_idx == end.paragraph {
|
|
end.offset
|
|
} else {
|
|
para_char_count
|
|
};
|
|
if range_start < range_end {
|
|
let mut char_pos = 0;
|
|
for span in para.children() {
|
|
let span_len = span.text.chars().count();
|
|
let span_start = char_pos;
|
|
let span_end = char_pos + span_len;
|
|
let sel_start = range_start.max(span_start);
|
|
let sel_end = range_end.min(span_end);
|
|
if sel_start < sel_end {
|
|
let rel_start = sel_start - span_start;
|
|
let rel_end = sel_end - span_start;
|
|
let text: String = span
|
|
.text
|
|
.chars()
|
|
.skip(rel_start)
|
|
.take(rel_end - rel_start)
|
|
.collect();
|
|
para_text.push_str(&text);
|
|
}
|
|
char_pos += span_len;
|
|
}
|
|
}
|
|
if para_idx > start.paragraph {
|
|
result.push('\n');
|
|
}
|
|
result.push_str(¶_text);
|
|
}
|
|
let mut bytes = result.into_bytes();
|
|
bytes.push(0);
|
|
crate::mem::write_bytes(bytes)
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool {
|
|
with_state!(state, {
|
|
if get_text_editor_state().active_shape_id.is_none() {
|
|
return false;
|
|
}
|
|
|
|
let sel = get_text_editor_state().selection;
|
|
|
|
// The frontend indexes these offsets into JS strings, which are UTF-16.
|
|
let (anchor_offset, focus_offset) = match get_text_editor_state()
|
|
.active_shape_id
|
|
.and_then(|shape_id| state.shapes.get(&shape_id))
|
|
.map(|shape| &shape.shape_type)
|
|
{
|
|
Some(Type::Text(text_content)) => {
|
|
let paragraphs = text_content.paragraphs();
|
|
let to_utf16 = |position: TextPositionWithAffinity| {
|
|
paragraphs
|
|
.get(position.paragraph)
|
|
.map(|para| para.char_offset_to_utf16(position.offset))
|
|
.unwrap_or(position.offset)
|
|
};
|
|
(to_utf16(sel.anchor), to_utf16(sel.focus))
|
|
}
|
|
_ => (sel.anchor.offset, sel.focus.offset),
|
|
};
|
|
|
|
unsafe {
|
|
*buffer_ptr = sel.anchor.paragraph as u32;
|
|
*buffer_ptr.add(1) = anchor_offset as u32;
|
|
*buffer_ptr.add(2) = sel.focus.paragraph as u32;
|
|
*buffer_ptr.add(3) = focus_offset as u32;
|
|
}
|
|
true
|
|
})
|
|
}
|
|
|
|
/// Writes where the text between the UTF-16 offsets starts in the text shape
|
|
/// (x, y, shape-local) and its box size (width, height); false when no text.
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub extern "C" fn text_get_range_origin(
|
|
a: u32,
|
|
b: u32,
|
|
c: u32,
|
|
d: u32,
|
|
start_paragraph: u32,
|
|
start_offset: u32,
|
|
end_paragraph: u32,
|
|
end_offset: u32,
|
|
buffer_ptr: *mut f32,
|
|
) -> Result<bool> {
|
|
with_state!(state, {
|
|
let Some(shape) = state.shapes.get(&uuid_from_u32_quartet(a, b, c, d)) else {
|
|
return Ok(false);
|
|
};
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return Ok(false);
|
|
};
|
|
|
|
let paragraphs = text_content.paragraphs();
|
|
let to_char = |paragraph: u32, offset: u32| {
|
|
let paragraph = paragraph as usize;
|
|
let offset = paragraphs
|
|
.get(paragraph)
|
|
.map(|para| para.utf16_offset_to_char(offset as usize))
|
|
.unwrap_or(offset as usize);
|
|
(paragraph, offset)
|
|
};
|
|
let Some(origin) = range_origin(
|
|
to_char(start_paragraph, start_offset),
|
|
to_char(end_paragraph, end_offset),
|
|
text_content,
|
|
shape,
|
|
) else {
|
|
return Ok(false);
|
|
};
|
|
let selrect = shape.selrect();
|
|
|
|
unsafe {
|
|
*buffer_ptr = origin.x;
|
|
*buffer_ptr.add(1) = origin.y;
|
|
*buffer_ptr.add(2) = selrect.width();
|
|
*buffer_ptr.add(3) = selrect.height();
|
|
}
|
|
Ok(true)
|
|
})
|
|
}
|