mirror of
https://github.com/penpot/penpot.git
synced 2026-04-27 12:18:32 +00:00
🎉 Add overtype mode to text editor
This commit is contained in:
parent
35f8e1b084
commit
ca228e7d34
@ -189,6 +189,13 @@
|
||||
(sync-wasm-text-editor-content!)
|
||||
(wasm.api/request-render "text-delete-forward"))
|
||||
|
||||
;; Insert
|
||||
(= key "Insert")
|
||||
(do
|
||||
(dom/prevent-default event)
|
||||
(text-editor/text-editor-toggle-overtype-mode)
|
||||
(wasm.api/request-render "text-overtype-mode"))
|
||||
|
||||
;; Arrow keys
|
||||
(= key "ArrowLeft")
|
||||
(do
|
||||
|
||||
@ -204,6 +204,7 @@
|
||||
(def text-editor-blur text-editor/text-editor-blur)
|
||||
(def text-editor-set-cursor-from-offset text-editor/text-editor-set-cursor-from-offset)
|
||||
(def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point)
|
||||
(def text-editor-toggle-overtype-mode text-editor/text-editor-toggle-overtype-mode)
|
||||
(def text-editor-pointer-down text-editor/text-editor-pointer-down)
|
||||
(def text-editor-pointer-move text-editor/text-editor-pointer-move)
|
||||
(def text-editor-pointer-up text-editor/text-editor-pointer-up)
|
||||
|
||||
@ -143,6 +143,12 @@
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y)))
|
||||
|
||||
(defn text-editor-toggle-overtype-mode
|
||||
"Toggles overtype mode"
|
||||
[]
|
||||
(when wasm/context-initialized?
|
||||
(h/call wasm/internal-module "_text_editor_toggle_overtype_mode")))
|
||||
|
||||
(defn text-editor-pointer-down
|
||||
[{:keys [x y]}]
|
||||
(when wasm/context-initialized?
|
||||
|
||||
@ -3,7 +3,7 @@ use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
|
||||
use crate::state::{TextEditorState, TextSelection};
|
||||
use crate::view::Viewbox;
|
||||
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
|
||||
use skia_safe::{BlendMode, Canvas, Paint, Rect};
|
||||
use skia_safe::{BlendMode, Canvas, Color, Paint, Rect};
|
||||
|
||||
pub fn render_overlay(
|
||||
canvas: &Canvas,
|
||||
@ -48,11 +48,26 @@ fn render_cursor(
|
||||
};
|
||||
|
||||
let mut cursor_rect = Rect::new_empty();
|
||||
cursor_rect.set_xywh(rect.x(), rect.y(), 1.5 / zoom, rect.height());
|
||||
cursor_rect.set_xywh(
|
||||
rect.x(),
|
||||
rect.y(),
|
||||
if editor_state.is_overtype_mode {
|
||||
rect.width()
|
||||
} else {
|
||||
editor_state.theme.cursor_width / zoom
|
||||
},
|
||||
rect.height(),
|
||||
);
|
||||
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(editor_state.theme.cursor_color);
|
||||
paint.set_anti_alias(true);
|
||||
paint.set_anti_alias(false);
|
||||
if editor_state.is_overtype_mode {
|
||||
paint.set_blend_mode(BlendMode::Exclusion);
|
||||
paint.set_color(Color::WHITE);
|
||||
} else {
|
||||
paint.set_blend_mode(BlendMode::SrcOver);
|
||||
paint.set_color(editor_state.theme.cursor_color);
|
||||
}
|
||||
|
||||
let shape_matrix = shape.get_matrix();
|
||||
canvas.save();
|
||||
@ -132,9 +147,9 @@ fn calculate_cursor_rect(
|
||||
.map(|span| span.text.chars().count())
|
||||
.sum();
|
||||
|
||||
let (cursor_x, cursor_y, cursor_height) = if para_char_count == 0 {
|
||||
let (cursor_x, cursor_y, cursor_width, cursor_height) = if para_char_count == 0 {
|
||||
// Empty paragraph - use default height
|
||||
(0.0, 0.0, laid_out_para.height())
|
||||
(0.0, 0.0, 1.0, laid_out_para.height())
|
||||
} else if char_pos == 0 {
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
0..1,
|
||||
@ -143,9 +158,9 @@ fn calculate_cursor_rect(
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
let r = &rects[0].rect;
|
||||
(r.left(), r.top(), r.height())
|
||||
(r.left(), r.top(), r.width(), r.height())
|
||||
} else {
|
||||
(0.0, 0.0, laid_out_para.height())
|
||||
(0.0, 0.0, 1.0, laid_out_para.height())
|
||||
}
|
||||
} else if char_pos >= para_char_count {
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
@ -155,9 +170,14 @@ fn calculate_cursor_rect(
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
let r = &rects[0].rect;
|
||||
(r.right(), r.top(), r.height())
|
||||
(r.right(), r.top(), r.width(), r.height())
|
||||
} else {
|
||||
(laid_out_para.longest_line(), 0.0, laid_out_para.height())
|
||||
(
|
||||
laid_out_para.longest_line(),
|
||||
0.0,
|
||||
1.0,
|
||||
laid_out_para.height(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let rects = laid_out_para.get_rects_for_range(
|
||||
@ -167,18 +187,18 @@ fn calculate_cursor_rect(
|
||||
);
|
||||
if !rects.is_empty() {
|
||||
let r = &rects[0].rect;
|
||||
(r.left(), r.top(), r.height())
|
||||
(r.left(), r.top(), r.width(), r.height())
|
||||
} else {
|
||||
// Fallback: use glyph position
|
||||
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
|
||||
(pos.position as f32, 0.0, laid_out_para.height())
|
||||
(pos.position as f32, 0.0, 1.0, laid_out_para.height())
|
||||
}
|
||||
};
|
||||
|
||||
return Some(Rect::from_xywh(
|
||||
cursor_x,
|
||||
y_offset + cursor_y,
|
||||
1.0, // cursor_width
|
||||
cursor_width, // cursor_width
|
||||
cursor_height,
|
||||
));
|
||||
}
|
||||
|
||||
@ -101,7 +101,8 @@ pub enum TextEditorEvent {
|
||||
/// FIXME: It should be better to get these constants from the frontend through the API.
|
||||
const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184);
|
||||
const CURSOR_COLOR: Color = Color::BLACK;
|
||||
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
|
||||
const CURSOR_WIDTH: f32 = 1.0;
|
||||
const CURSOR_BLINK_INTERVAL_MS: f32 = 530.0;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TextEditorStyles {
|
||||
@ -257,6 +258,7 @@ impl TextEditorStyles {
|
||||
pub struct TextEditorTheme {
|
||||
pub selection_color: Color,
|
||||
pub cursor_color: Color,
|
||||
pub cursor_width: f32,
|
||||
}
|
||||
|
||||
pub struct TextComposition {
|
||||
@ -326,9 +328,10 @@ pub struct TextEditorState {
|
||||
// selecting something with the pointer.
|
||||
pub is_pointer_selection_active: bool,
|
||||
pub is_click_event_skipped: bool,
|
||||
pub is_overtype_mode: bool,
|
||||
pub active_shape_id: Option<Uuid>,
|
||||
pub cursor_visible: bool,
|
||||
pub last_blink_time: f64,
|
||||
pub last_blink_time_ms: f32,
|
||||
pub current_styles: TextEditorStyles,
|
||||
pending_events: Vec<TextEditorEvent>,
|
||||
}
|
||||
@ -339,15 +342,17 @@ impl TextEditorState {
|
||||
theme: TextEditorTheme {
|
||||
selection_color: SELECTION_COLOR,
|
||||
cursor_color: CURSOR_COLOR,
|
||||
cursor_width: CURSOR_WIDTH,
|
||||
},
|
||||
selection: TextSelection::new(),
|
||||
composition: TextComposition::new(),
|
||||
has_focus: false,
|
||||
is_pointer_selection_active: false,
|
||||
is_click_event_skipped: false,
|
||||
is_overtype_mode: false,
|
||||
active_shape_id: None,
|
||||
cursor_visible: true,
|
||||
last_blink_time: 0.0,
|
||||
last_blink_time_ms: 0.0,
|
||||
pending_events: Vec::new(),
|
||||
current_styles: TextEditorStyles::new(),
|
||||
}
|
||||
@ -357,9 +362,10 @@ impl TextEditorState {
|
||||
self.has_focus = true;
|
||||
self.active_shape_id = Some(shape_id);
|
||||
self.cursor_visible = true;
|
||||
self.last_blink_time = 0.0;
|
||||
self.last_blink_time_ms = 0.0;
|
||||
self.selection.reset();
|
||||
self.is_pointer_selection_active = false;
|
||||
self.is_overtype_mode = false;
|
||||
self.pending_events.clear();
|
||||
}
|
||||
|
||||
@ -367,9 +373,10 @@ impl TextEditorState {
|
||||
self.has_focus = false;
|
||||
// self.active_shape_id = None;
|
||||
self.cursor_visible = false;
|
||||
self.last_blink_time = 0.0;
|
||||
self.last_blink_time_ms = 0.0;
|
||||
// self.selection.reset();
|
||||
self.is_pointer_selection_active = false;
|
||||
self.is_overtype_mode = false;
|
||||
self.pending_events.clear();
|
||||
}
|
||||
|
||||
@ -377,9 +384,10 @@ impl TextEditorState {
|
||||
self.has_focus = false;
|
||||
self.active_shape_id = None;
|
||||
self.cursor_visible = false;
|
||||
self.last_blink_time = 0.0;
|
||||
self.last_blink_time_ms = 0.0;
|
||||
self.selection.reset();
|
||||
self.is_pointer_selection_active = false;
|
||||
self.is_overtype_mode = false;
|
||||
self.pending_events.clear();
|
||||
}
|
||||
|
||||
@ -516,6 +524,14 @@ impl TextEditorState {
|
||||
self.push_event(TextEditorEvent::SelectionChanged);
|
||||
}
|
||||
|
||||
pub fn set_overtype_mode(&mut self, overtype_mode: bool) {
|
||||
self.is_overtype_mode = overtype_mode;
|
||||
}
|
||||
|
||||
pub fn toggle_overtype_mode(&mut self) {
|
||||
self.set_overtype_mode(!self.is_overtype_mode);
|
||||
}
|
||||
|
||||
fn update_styles_from_selection(&mut self, text_content: &TextContent) -> bool {
|
||||
let paragraphs = text_content.paragraphs();
|
||||
if paragraphs.is_empty() {
|
||||
@ -687,27 +703,27 @@ impl TextEditorState {
|
||||
styles_were_updated
|
||||
}
|
||||
|
||||
pub fn update_blink(&mut self, timestamp_ms: f64) {
|
||||
pub fn update_blink(&mut self, timestamp_ms: f32) {
|
||||
if !self.has_focus {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.last_blink_time == 0.0 {
|
||||
self.last_blink_time = timestamp_ms;
|
||||
if self.last_blink_time_ms == 0.0 {
|
||||
self.last_blink_time_ms = timestamp_ms;
|
||||
self.cursor_visible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let elapsed = timestamp_ms - self.last_blink_time;
|
||||
let elapsed = timestamp_ms - self.last_blink_time_ms;
|
||||
if elapsed >= CURSOR_BLINK_INTERVAL_MS {
|
||||
self.cursor_visible = !self.cursor_visible;
|
||||
self.last_blink_time = timestamp_ms;
|
||||
self.last_blink_time_ms = timestamp_ms;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_blink(&mut self) {
|
||||
self.cursor_visible = true;
|
||||
self.last_blink_time = 0.0;
|
||||
self.last_blink_time_ms = 0.0;
|
||||
}
|
||||
|
||||
pub fn push_event(&mut self, event: TextEditorEvent) {
|
||||
|
||||
@ -276,6 +276,43 @@ pub fn find_text_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<
|
||||
None
|
||||
}
|
||||
|
||||
pub fn replace_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) = replace_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) = replace_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, splitting on newlines into multiple paragraphs.
|
||||
/// Returns the final cursor position after insertion.
|
||||
pub fn insert_text_with_newlines(
|
||||
@ -356,6 +393,58 @@ pub fn insert_text_at_cursor(
|
||||
Some(cursor.offset + text.chars().count())
|
||||
}
|
||||
|
||||
/// Replace text at cursor position (overtype mode). Replaces N characters where N is the
|
||||
/// length of the input text, returning the new cursor offset.
|
||||
pub fn replace_text_at_cursor(
|
||||
text_content: &mut TextContent,
|
||||
cursor: &TextPositionWithAffinity,
|
||||
text: &str,
|
||||
) -> Option<usize> {
|
||||
let text_len = text.chars().count();
|
||||
if text_len == 0 {
|
||||
return Some(cursor.offset);
|
||||
}
|
||||
|
||||
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_len);
|
||||
}
|
||||
|
||||
let (span_idx, offset_in_span) = find_text_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());
|
||||
|
||||
let end_byte_offset = new_text
|
||||
.char_indices()
|
||||
.nth(offset_in_span + text_len)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(new_text.len());
|
||||
|
||||
new_text.replace_range(byte_offset..end_byte_offset, text);
|
||||
span.set_text(new_text);
|
||||
|
||||
Some(cursor.offset + text_len)
|
||||
}
|
||||
|
||||
/// Delete a range of text specified by a selection.
|
||||
pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) {
|
||||
let start = selection.start();
|
||||
|
||||
@ -455,6 +455,15 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
pub extern "C" fn text_editor_toggle_overtype_mode() -> Result<()> {
|
||||
with_state_mut!(state, {
|
||||
state.text_editor_state.toggle_overtype_mode();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
// FIXME: Review if all the return Ok(()) should be Err instead.
|
||||
#[no_mangle]
|
||||
#[wasm_error]
|
||||
@ -491,11 +500,16 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
|
||||
}
|
||||
|
||||
let cursor = state.text_editor_state.selection.focus;
|
||||
|
||||
if let Some(new_cursor) =
|
||||
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
|
||||
if !state.text_editor_state.is_overtype_mode {
|
||||
if let Some(new_cursor) =
|
||||
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
|
||||
{
|
||||
state.text_editor_state.selection.set_caret(new_cursor);
|
||||
}
|
||||
} else if let Some(new_cursor) =
|
||||
text_helpers::replace_text_with_newlines(text_content, &cursor, &text)
|
||||
{
|
||||
state.text_editor_state.selection.set_caret(new_cursor);
|
||||
state.text_editor_state.selection.set_caret(new_cursor);
|
||||
}
|
||||
|
||||
text_content.layout.paragraphs.clear();
|
||||
@ -876,7 +890,7 @@ pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 {
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn text_editor_update_blink(timestamp_ms: f64) {
|
||||
pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) {
|
||||
with_state_mut!(state, {
|
||||
state.text_editor_state.update_blink(timestamp_ms);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user