use std::sync::Arc;
use blinc_core::context_state::BlincContextState;
use blinc_core::{Brush, Color, CornerRadius, DrawContext, Rect, State};
use crate::canvas::{canvas, Canvas, CanvasBounds};
use crate::div::{div, Div, ElementBuilder};
use crate::stateful::{stateful_with_key, NoState};
use crate::widgets::cursor::SharedCursorState;
use super::block_ops::{indent_blocks, outdent_blocks, toggle_block_kind};
use super::cursor::{step_backward, step_forward, DocPosition};
use super::document::BlockKind;
use super::edit::{
delete_backward, delete_forward, delete_selection, insert_char, insert_text, soft_break,
split_block,
};
use super::format::{apply_mark_to_selection, Mark};
use super::render::{compute_line_geometry, render_document, RichTextTheme};
use super::state::RichTextState;
pub fn rich_text_editor(
state: &RichTextState,
theme: RichTextTheme,
content_width: f32,
) -> impl ElementBuilder {
let blinc = BlincContextState::get();
let version_key = format!("rich_text_editor:{:p}", Arc::as_ptr(state));
let version: State<u32> = blinc.use_state_keyed(&version_key, || 0);
let stateful_key = format!("rich_text_editor_root:{:p}", Arc::as_ptr(state));
{
if let Ok(mut data) = state.lock() {
let geometry = compute_line_geometry(&data.document, &theme, content_width);
data.set_line_index(geometry);
}
}
let state_for_render = Arc::clone(state);
let state_for_click = Arc::clone(state);
let state_for_drag = Arc::clone(state);
let state_for_key = Arc::clone(state);
let state_for_text = Arc::clone(state);
let theme_for_render = theme.clone();
let version_for_render = version.clone();
let version_for_click = version.clone();
let version_for_drag = version.clone();
let version_for_key = version.clone();
let version_for_text = version.clone();
stateful_with_key::<NoState>(&stateful_key)
.deps([version.signal_id()])
.on_state(move |_ctx| {
let mut data = state_for_render.lock().unwrap();
let geometry = compute_line_geometry(&data.document, &theme_for_render, content_width);
data.set_line_index(geometry);
data.set_cursor_visible(data.focused);
let content_height = data
.line_index
.iter()
.map(|g| g.y + g.height)
.fold(0.0_f32, f32::max)
.max(theme_for_render.font_size * theme_for_render.line_height);
let doc_tree = render_document(&data.document, &theme_for_render, content_width);
let mut overlay_children: Vec<Div> = Vec::new();
if data.focused {
if let Some(sel) = data.selection {
if !sel.is_empty() {
overlay_children.extend(selection_rects(&data, sel, &theme_for_render));
}
}
}
let mut root = div()
.w_full()
.relative()
.cursor_text()
.class("blinc-rich-text-editor")
.child(doc_tree);
for child in overlay_children {
root = root.child(child);
}
root = root.child(cursor_overlay_canvas(
Arc::clone(&state_for_render),
data.cursor_state.clone(),
theme_for_render.text,
content_width,
content_height,
));
data.toolbar_rect = None;
drop(data);
if let Some(toolbar) = super::toolbar::selection_toolbar(
&state_for_render,
&version_for_render,
&theme_for_render,
) {
root = root.child(toolbar);
}
root
})
.w_full()
.on_mouse_down(move |ctx| {
let mut data = state_for_click.lock().unwrap();
data.set_focus(true);
data.editor_bounds = (
ctx.bounds_x,
ctx.bounds_y,
ctx.bounds_width,
ctx.bounds_height,
);
crate::widgets::text_input::bump_focus_tap_generation();
let blur_state = Arc::clone(&state_for_click);
crate::widgets::text_input::set_focused_editable_node(
ctx.node_id,
Some(Box::new(move || {
if let Ok(mut data) = blur_state.lock() {
data.set_focus(false);
}
})),
);
if data.suppress_next_outer_click {
data.suppress_next_outer_click = false;
return;
}
let now = web_time::Instant::now();
let is_double = data
.last_click_time
.map(|t| now.duration_since(t).as_millis() < 400)
.unwrap_or(false);
data.last_click_time = Some(now);
let touch = crate::widgets::text_input::is_touch_input();
if let Some(pos) = data.position_from_click(ctx.local_x, ctx.local_y) {
if is_double {
if let Some(line) = data
.document
.blocks
.get(pos.block)
.and_then(|b| b.lines.get(pos.line))
{
let (start_col, end_col) =
crate::widgets::text_edit::word_at_position(&line.text, pos.col);
data.cursor = super::cursor::DocPosition::new(pos.block, pos.line, end_col);
data.selection = Some(super::cursor::Selection {
anchor: super::cursor::DocPosition::new(pos.block, pos.line, start_col),
head: super::cursor::DocPosition::new(pos.block, pos.line, end_col),
});
data.reset_cursor_blink();
if touch {
crate::widgets::text_edit::haptic_impact_light();
use crate::widgets::text_edit::edit_menu_actions;
crate::widgets::text_edit::show_edit_menu(
ctx.bounds_x + ctx.local_x,
ctx.bounds_y + ctx.local_y,
ctx.bounds_x + ctx.local_x,
ctx.bounds_y + ctx.local_y,
0.0,
24.0,
edit_menu_actions::CUT
| edit_menu_actions::COPY
| edit_menu_actions::PASTE
| edit_menu_actions::SELECT_ALL,
);
}
}
} else if touch {
data.move_cursor(pos, false);
crate::widgets::text_edit::haptic_selection();
crate::widgets::text_edit::hide_edit_menu();
let captured_pos = pos;
let state_for_long_press = Arc::clone(&state_for_click);
let version_for_long_press = version_for_click.clone();
crate::widgets::text_input::arm_long_press_timer(
ctx.bounds_x + ctx.local_x,
ctx.bounds_y + ctx.local_y,
24.0,
Some(Box::new(move || {
let did_update = {
let mut data = match state_for_long_press.lock() {
Ok(d) => d,
Err(_) => return,
};
if !data.focused {
return;
}
let line_text = data
.document
.blocks
.get(captured_pos.block)
.and_then(|b| b.lines.get(captured_pos.line))
.map(|l| l.text.clone());
let Some(line_text) = line_text else {
return;
};
let (start_col, end_col) =
crate::widgets::text_edit::word_at_position(
&line_text,
captured_pos.col,
);
if start_col == end_col {
return;
}
data.cursor = super::cursor::DocPosition::new(
captured_pos.block,
captured_pos.line,
end_col,
);
data.selection = Some(super::cursor::Selection {
anchor: super::cursor::DocPosition::new(
captured_pos.block,
captured_pos.line,
start_col,
),
head: super::cursor::DocPosition::new(
captured_pos.block,
captured_pos.line,
end_col,
),
});
data.reset_cursor_blink();
true
};
if did_update {
version_for_long_press
.set(version_for_long_press.get().wrapping_add(1));
}
})),
);
} else {
let extend = ctx.shift;
data.move_cursor(pos, extend);
}
}
drop(data);
version_for_click.set(version_for_click.get().wrapping_add(1));
})
.on_drag(move |ctx| {
let mut data = state_for_drag.lock().unwrap();
if !data.focused {
return;
}
let touch = crate::widgets::text_input::is_touch_input();
if let Some(pos) = data.position_from_click(ctx.local_x, ctx.local_y) {
if touch {
crate::widgets::text_input::check_long_press_drift(ctx.mouse_x, ctx.mouse_y);
if data.cursor != pos {
data.move_cursor(pos, false);
crate::widgets::text_edit::haptic_selection();
}
} else {
data.move_cursor(pos, true);
}
}
drop(data);
version_for_drag.set(version_for_drag.get().wrapping_add(1));
})
.on_text_input(move |ctx| {
let ch: char = match ctx.key_char {
Some(c) => c,
None => return,
};
if ch.is_control() {
return;
}
let mut data = state_for_text.lock().unwrap();
if !data.focused {
return;
}
if let super::state::PickerState::Link { ref mut draft } = data.picker {
draft.push(ch);
drop(data);
version_for_text.set(version_for_text.get().wrapping_add(1));
return;
}
let mut pos = data.cursor;
if let Some(sel) = data.selection.take() {
pos = delete_selection(&mut data.document, sel);
}
data.push_undo();
let fmt = data.active_format.clone();
let new_pos = insert_char(&mut data.document, pos, ch, &fmt);
data.set_cursor(new_pos);
drop(data);
version_for_text.set(version_for_text.get().wrapping_add(1));
})
.on_key_down(move |ctx| {
let mut data = state_for_key.lock().unwrap();
if !data.focused {
return;
}
if matches!(data.picker, super::state::PickerState::Link { .. }) {
match ctx.key_code {
8 => {
if let super::state::PickerState::Link { ref mut draft } = data.picker {
draft.pop();
drop(data);
version_for_key.set(version_for_key.get().wrapping_add(1));
return;
}
}
13 => {
if let super::state::PickerState::Link { draft } = data.picker.clone() {
if !draft.is_empty() {
if let Some(sel) = data.selection {
if !sel.is_empty() {
data.push_undo();
super::format::apply_mark_to_selection(
&mut data.document,
sel,
super::format::Mark::Link(Some(draft)),
);
}
}
}
data.picker = super::state::PickerState::None;
drop(data);
version_for_key.set(version_for_key.get().wrapping_add(1));
return;
}
}
27 => {
data.picker = super::state::PickerState::None;
drop(data);
version_for_key.set(version_for_key.get().wrapping_add(1));
return;
}
_ => {}
}
}
let mod_key = ctx.meta || ctx.ctrl;
let extend = ctx.shift;
let mut changed = false;
if mod_key {
match ctx.key_code {
90 if !ctx.shift => {
changed |= data.undo();
}
90 if ctx.shift => {
changed |= data.redo();
}
65 => {
let last_block = data.document.blocks.len().saturating_sub(1);
let last_line = data.document.blocks[last_block]
.lines
.len()
.saturating_sub(1);
let last_col = data.document.blocks[last_block].lines[last_line]
.text
.chars()
.count();
data.selection = Some(super::cursor::Selection {
anchor: DocPosition::ZERO,
head: DocPosition::new(last_block, last_line, last_col),
});
data.cursor = DocPosition::new(last_block, last_line, last_col);
data.reset_cursor_blink();
changed = true;
}
66 => {
changed |= toggle_mark(&mut data, Mark::Bold);
}
73 => {
changed |= toggle_mark(&mut data, Mark::Italic);
}
85 => {
changed |= toggle_mark(&mut data, Mark::Underline);
}
88 if ctx.shift => {
changed |= toggle_mark(&mut data, Mark::Strikethrough);
}
69 => {
changed |= toggle_mark(&mut data, Mark::Code);
}
67 => {
if let Some(sel) = data.selection {
if !sel.is_empty() {
let (s, e) = sel.ordered();
let plain = data.document.plain_text_range(s, e);
if !plain.is_empty() {
crate::widgets::text_edit::clipboard_write(&plain);
}
}
}
}
88 if !ctx.shift => {
if let Some(sel) = data.selection {
if !sel.is_empty() {
let (s, e) = sel.ordered();
let plain = data.document.plain_text_range(s, e);
if !plain.is_empty() {
crate::widgets::text_edit::clipboard_write(&plain);
}
data.push_undo();
data.selection = None;
let new_pos = delete_selection(&mut data.document, sel);
data.set_cursor(new_pos);
changed = true;
}
}
}
86 => {
if let Some(text) = crate::widgets::text_edit::clipboard_read() {
if !text.is_empty() {
data.push_undo();
let mut pos = data.cursor;
if let Some(sel) = data.selection.take() {
pos = delete_selection(&mut data.document, sel);
}
let fmt = data.active_format.clone();
let new_pos = insert_text(&mut data.document, pos, &text, &fmt);
data.set_cursor(new_pos);
changed = true;
}
}
}
48 if ctx.alt => {
changed |= apply_block_kind(&mut data, BlockKind::Paragraph);
}
49 if ctx.alt => {
changed |= apply_block_kind(&mut data, BlockKind::Heading(1));
}
50 if ctx.alt => {
changed |= apply_block_kind(&mut data, BlockKind::Heading(2));
}
51 if ctx.alt => {
changed |= apply_block_kind(&mut data, BlockKind::Heading(3));
}
52 if ctx.alt => {
changed |= apply_block_kind(&mut data, BlockKind::Heading(4));
}
53 if ctx.alt => {
changed |= apply_block_kind(&mut data, BlockKind::Heading(5));
}
54 if ctx.alt => {
changed |= apply_block_kind(&mut data, BlockKind::Heading(6));
}
55 if ctx.shift => {
changed |= apply_block_kind(&mut data, BlockKind::NumberedItem);
}
56 if ctx.shift => {
changed |= apply_block_kind(&mut data, BlockKind::BulletItem);
}
57 if ctx.shift => {
changed |= apply_block_kind(&mut data, BlockKind::Quote);
}
_ => {}
}
if changed {
drop(data);
version_for_key.set(version_for_key.get().wrapping_add(1));
return;
}
}
match ctx.key_code {
37 => {
if mod_key {
let pos = home_of(&data);
data.move_cursor(pos, extend);
changed = true;
} else if let Some(pos) = step_backward(&data.document, data.cursor) {
data.move_cursor(pos, extend);
changed = true;
}
}
39 => {
if mod_key {
let pos = end_of(&data);
data.move_cursor(pos, extend);
changed = true;
} else if let Some(pos) = step_forward(&data.document, data.cursor) {
data.move_cursor(pos, extend);
changed = true;
}
}
38 => {
if let Some(pos) = move_vertical(&data, -1) {
data.move_cursor(pos, extend);
changed = true;
}
}
40 => {
if let Some(pos) = move_vertical(&data, 1) {
data.move_cursor(pos, extend);
changed = true;
}
}
36 => {
let pos = home_of(&data);
data.move_cursor(pos, extend);
changed = true;
}
35 => {
let pos = end_of(&data);
data.move_cursor(pos, extend);
changed = true;
}
8 => {
let pos = data.cursor;
let sel = data.selection.take();
data.push_undo();
let new_pos = if let Some(s) = sel {
delete_selection(&mut data.document, s)
} else {
delete_backward(&mut data.document, pos)
};
data.set_cursor(new_pos);
changed = true;
}
127 => {
let pos = data.cursor;
let sel = data.selection.take();
data.push_undo();
let new_pos = if let Some(s) = sel {
delete_selection(&mut data.document, s)
} else {
delete_forward(&mut data.document, pos)
};
data.set_cursor(new_pos);
changed = true;
}
13 => {
let mut pos = data.cursor;
if let Some(s) = data.selection.take() {
pos = delete_selection(&mut data.document, s);
}
data.push_undo();
let new_pos = if ctx.shift {
soft_break(&mut data.document, pos)
} else {
split_block(&mut data.document, pos)
};
data.set_cursor(new_pos);
changed = true;
}
27 => {
data.set_focus(false);
changed = true;
}
9 => {
let range = block_selection(&data);
let did_change = if ctx.shift {
data.push_undo();
let r = outdent_blocks(&mut data.document, range);
if !r {
data.undo_stack.pop();
}
r
} else {
data.push_undo();
let r = indent_blocks(&mut data.document, range);
if !r {
data.undo_stack.pop();
}
r
};
if did_change {
let cursor = data.cursor;
data.set_cursor(cursor);
changed = true;
}
}
_ => {}
}
drop(data);
if changed {
version_for_key.set(version_for_key.get().wrapping_add(1));
}
})
}
fn block_selection(data: &super::state::RichTextData) -> super::cursor::Selection {
if let Some(sel) = data.selection {
if !sel.is_empty() {
return sel;
}
}
super::cursor::Selection {
anchor: data.cursor,
head: data.cursor,
}
}
fn apply_block_kind(data: &mut super::state::RichTextData, kind: BlockKind) -> bool {
let range = block_selection(data);
data.push_undo();
let changed = toggle_block_kind(&mut data.document, range, kind);
if !changed {
data.undo_stack.pop();
return false;
}
let cursor = data.cursor;
data.set_cursor(cursor);
true
}
fn cursor_overlay_canvas(
state: RichTextState,
cursor_state: SharedCursorState,
color: Color,
width: f32,
height: f32,
) -> Canvas {
canvas(move |ctx: &mut dyn DrawContext, _bounds: CanvasBounds| {
let (opacity, visible) = match cursor_state.lock() {
Ok(s) => (s.current_opacity(), s.visible),
Err(_) => (1.0, false),
};
if !visible || opacity < 0.01 {
return;
}
let Ok(data) = state.lock() else {
return;
};
if !data.focused {
return;
}
let Some((x, y, h)) = data.cursor_geometry() else {
return;
};
let c = Color::rgba(color.r, color.g, color.b, color.a * opacity);
ctx.fill_rect(
Rect::new(x, y, 2.0, h),
CornerRadius::default(),
Brush::Solid(c),
);
})
.absolute()
.left(0.0)
.top(0.0)
.w(width)
.h(height.max(1.0))
}
fn selection_rects(
data: &super::state::RichTextData,
sel: super::cursor::Selection,
theme: &RichTextTheme,
) -> Vec<Div> {
let (start, end) = sel.ordered();
let mut rects = Vec::new();
let highlight = Color::rgba(theme.text.r, theme.text.g, theme.text.b, 0.18);
for g in &data.line_index {
let line_chars = g.total_chars();
let line_end_col = g.start.col + line_chars;
let on_block = g.start.block;
let on_line = g.start.line;
let after_start = (on_block, on_line, line_end_col) >= (start.block, start.line, start.col);
let before_end = (on_block, on_line, g.start.col) <= (end.block, end.line, end.col);
if !(after_start && before_end) {
continue;
}
let line_start_pos = (on_block, on_line, g.start.col);
let line_end_pos = (on_block, on_line, line_end_col);
let sel_start_pos = (start.block, start.line, start.col);
let sel_end_pos = (end.block, end.line, end.col);
let sx_global = sel_start_pos.max(line_start_pos);
let ex_global = sel_end_pos.min(line_end_pos);
if sx_global >= ex_global {
continue;
}
let local_start = sx_global.2 - g.start.col;
let local_end = ex_global.2 - g.start.col;
let start_px = super::state::pixel_x_for_local_col(g, local_start);
let end_px = super::state::pixel_x_for_local_col(g, local_end);
let mid_w = end_px - start_px;
if mid_w <= 0.0 {
continue;
}
rects.push(
div()
.absolute()
.left(g.x + start_px)
.top(g.y)
.w(mid_w)
.h(g.height)
.bg(highlight),
);
}
rects
}
fn toggle_mark(data: &mut super::state::RichTextData, mark: Mark) -> bool {
if let Some(sel) = data.selection {
if !sel.is_empty() {
data.push_undo();
let changed = apply_mark_to_selection(&mut data.document, sel, mark.clone());
data.active_format =
super::cursor::ActiveFormat::from_position(&data.document, data.cursor);
data.reset_cursor_blink();
return changed;
}
}
let fmt = &mut data.active_format;
match mark {
Mark::Bold => fmt.bold = !fmt.bold,
Mark::Italic => fmt.italic = !fmt.italic,
Mark::Underline => fmt.underline = !fmt.underline,
Mark::Strikethrough => fmt.strikethrough = !fmt.strikethrough,
Mark::Code => fmt.code = !fmt.code,
Mark::Color(c) => fmt.color = Some(c),
Mark::Link(url) => fmt.link = url,
}
data.reset_cursor_blink();
true
}
fn move_vertical(data: &super::state::RichTextData, dir: i32) -> Option<DocPosition> {
let cursor = data.cursor;
let current_idx = data.line_index.iter().position(|g| {
if g.start.block != cursor.block || g.start.line != cursor.line {
return false;
}
let end_col = g.start.col + g.total_chars();
cursor.col >= g.start.col && cursor.col <= end_col
})?;
let target_idx = if dir < 0 {
current_idx.checked_sub(1)?
} else {
let n = current_idx + 1;
if n >= data.line_index.len() {
return None;
}
n
};
let g = &data.line_index[target_idx];
let local_col = cursor
.col
.saturating_sub(data.line_index[current_idx].start.col);
let new_col = g.start.col + local_col.min(g.total_chars());
Some(DocPosition::new(g.start.block, g.start.line, new_col))
}
fn home_of(data: &super::state::RichTextData) -> DocPosition {
let cursor = data.cursor;
for g in &data.line_index {
if g.start.block == cursor.block && g.start.line == cursor.line {
let end_col = g.start.col + g.total_chars();
if cursor.col >= g.start.col && cursor.col <= end_col {
return DocPosition::new(g.start.block, g.start.line, g.start.col);
}
}
}
cursor
}
fn end_of(data: &super::state::RichTextData) -> DocPosition {
let cursor = data.cursor;
for g in &data.line_index {
if g.start.block == cursor.block && g.start.line == cursor.line {
let end_col = g.start.col + g.total_chars();
if cursor.col >= g.start.col && cursor.col <= end_col {
return DocPosition::new(g.start.block, g.start.line, end_col);
}
}
}
cursor
}