diff --git a/Cargo.toml b/Cargo.toml index 59b46705cb..931c283788 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -160,7 +160,7 @@ web-sys = { version = "=0.3.98", features = [ winit = { git = "https://github.com/rust-windowing/winit.git" } keyboard-types = "0.8" url = "2.5" -tokio = { version = "1.29", features = ["fs", "macros", "io-std", "rt", "rt-multi-thread"] } +tokio = { version = "1.29", features = ["fs", "macros", "io-std", "rt", "rt-multi-thread", "time"] } # Linebender ecosystem (BEGIN) kurbo = { version = "0.13", features = ["serde"] } vello = "0.9" diff --git a/editor/src/messages/clipboard/clipboard_message_handler.rs b/editor/src/messages/clipboard/clipboard_message_handler.rs index 3e3481e7ec..1d2cbc701f 100644 --- a/editor/src/messages/clipboard/clipboard_message_handler.rs +++ b/editor/src/messages/clipboard/clipboard_message_handler.rs @@ -47,6 +47,8 @@ impl MessageHandler> for Clipboard ClipboardContentRaw::Text(text) => { if let Some(graphite) = text.strip_prefix(CLIPBOARD_PREFIX) { responses.add(ClipboardMessage::PasteItems { data: graphite.to_string() }); + } else if current_tool == &ToolType::Text { + responses.add(TextToolMessage::PasteText { text }); } else { responses.add(FrontendMessage::TriggerSelectionWrite { content: text }); } @@ -100,6 +102,10 @@ impl MessageHandler> for Clipboard responses.add(PathToolMessage::Copy); return; } + if current_tool == &ToolType::Text { + responses.add(TextToolMessage::Copy); + return; + } let Some(active_document) = portfolio.active_document_id.and_then(|id| portfolio.documents.get_mut(&id)) else { return; @@ -157,6 +163,10 @@ impl MessageHandler> for Clipboard responses.add(PathToolMessage::Cut); return; } + if current_tool == &ToolType::Text { + responses.add(TextToolMessage::Cut); + return; + } if let Some(active_document) = portfolio.active_document() && active_document.graph_view_overlay_open() diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 5d9e73e765..fde87124ee 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -36,32 +36,6 @@ pub enum FrontendMessage { #[serde(rename = "panicInfo")] panic_info: String, }, - DisplayEditableTextbox { - text: String, - #[serde(rename = "lineHeightRatio")] - line_height_ratio: f64, - #[serde(rename = "fontSize")] - font_size: f64, - color: String, - #[serde(rename = "fontData")] - font_data: serde_bytes::ByteBuf, - transform: [f64; 6], - #[serde(rename = "maxWidth")] - max_width: Option, - #[serde(rename = "maxHeight")] - max_height: Option, - align: String, - #[serde(rename = "alignLast")] - align_last: String, - }, - DisplayEditableTextboxUpdateFontData { - #[serde(rename = "fontData")] - font_data: serde_bytes::ByteBuf, - }, - DisplayEditableTextboxTransform { - transform: [f64; 6], - }, - DisplayRemoveEditableTextbox, // Send prefix: Send global, static data to the frontend that is never updated SendUIMetadata { @@ -135,7 +109,6 @@ pub enum FrontendMessage { #[cfg_attr(feature = "wasm", tsify(type = "unknown"))] preferences: PreferencesMessageHandler, }, - TriggerTextCommit, /// Asks the frontend to enter inline-rename mode for a layer in the graph view. TriggerEditLayerNameInGraph { #[serde(rename = "nodeId")] @@ -206,6 +179,10 @@ pub enum FrontendMessage { UpdateGraphViewOverlay { open: bool, }, + UpdateTextEditingState { + #[serde(rename = "isEditing")] + is_editing: bool, + }, UpdateWorkspacePanelLayout { #[serde(rename = "panelLayout")] panel_layout: WorkspacePanelLayout, diff --git a/editor/src/messages/input_mapper/input_mapper_message.rs b/editor/src/messages/input_mapper/input_mapper_message.rs index bfcda26c73..d90167a258 100644 --- a/editor/src/messages/input_mapper/input_mapper_message.rs +++ b/editor/src/messages/input_mapper/input_mapper_message.rs @@ -16,6 +16,8 @@ pub enum InputMapperMessage { KeyUpNoRepeat(Key), #[child] DoubleClick(MouseButton), + #[child] + TripleClick(MouseButton), // Messages PointerMove, diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 8023d0f926..f3050b7c0e 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -168,10 +168,54 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { // TextToolMessage entry!(PointerMove; refresh_keys=[Alt, Shift], action_dispatch=TextToolMessage::PointerMove { center: Alt, lock_ratio: Shift }), entry!(KeyDown(MouseLeft); action_dispatch=TextToolMessage::DragStart), + entry!(DoubleClick(MouseButton::Left); action_dispatch=TextToolMessage::DoubleClick), + entry!(TripleClick(MouseButton::Left); action_dispatch=TextToolMessage::TripleClick), entry!(KeyUp(MouseLeft); action_dispatch=TextToolMessage::DragStop), entry!(KeyDown(MouseRight); action_dispatch=TextToolMessage::Abort), entry!(KeyDown(Escape); action_dispatch=TextToolMessage::Abort), entry!(KeyDown(Enter); modifiers=[Accel], action_dispatch=TextToolMessage::Abort), + entry!(KeyDown(Backspace); action_dispatch=TextToolMessage::BackspaceChar), + entry!(KeyDown(Delete); action_dispatch=TextToolMessage::DeleteChar), + entry!(KeyDown(ArrowLeft); action_dispatch=TextToolMessage::MoveCursorLeft { word: false, select: false }), + entry!(KeyDown(ArrowLeft); modifiers=[Alt], action_dispatch=TextToolMessage::MoveCursorLeft { word: true, select: false }), + entry!(KeyDown(ArrowLeft); modifiers=[Control], action_dispatch=TextToolMessage::MoveCursorLeft { word: true, select: false }), + entry!(KeyDown(ArrowRight); action_dispatch=TextToolMessage::MoveCursorRight { word: false, select: false }), + entry!(KeyDown(ArrowRight); modifiers=[Alt], action_dispatch=TextToolMessage::MoveCursorRight { word: true, select: false }), + entry!(KeyDown(ArrowRight); modifiers=[Control], action_dispatch=TextToolMessage::MoveCursorRight { word: true, select: false }), + entry!(KeyDown(ArrowUp); action_dispatch=TextToolMessage::MoveCursorUp { select: false }), + entry!(KeyDown(ArrowUp); modifiers=[Command], action_dispatch=TextToolMessage::MoveCursorTop { select: false }), + entry!(KeyDown(ArrowUp); modifiers=[Control], action_dispatch=TextToolMessage::MoveCursorTop { select: false }), + entry!(KeyDown(ArrowDown); action_dispatch=TextToolMessage::MoveCursorDown { select: false }), + entry!(KeyDown(ArrowDown); modifiers=[Command], action_dispatch=TextToolMessage::MoveCursorBottom { select: false }), + entry!(KeyDown(ArrowDown); modifiers=[Control], action_dispatch=TextToolMessage::MoveCursorBottom { select: false }), + entry!(KeyDown(Home); action_dispatch=TextToolMessage::MoveCursorHome { select: false }), + entry!(KeyDown(ArrowLeft); modifiers=[Command], action_dispatch=TextToolMessage::MoveCursorHome { select: false }), + entry!(KeyDown(End); action_dispatch=TextToolMessage::MoveCursorEnd { select: false }), + entry!(KeyDown(ArrowRight); modifiers=[Command], action_dispatch=TextToolMessage::MoveCursorEnd { select: false }), + entry!(KeyDown(ArrowLeft); modifiers=[Shift], action_dispatch=TextToolMessage::MoveCursorLeft { word: false, select: true }), + entry!(KeyDown(ArrowLeft); modifiers=[Shift, Alt], action_dispatch=TextToolMessage::MoveCursorLeft { word: true, select: true }), + entry!(KeyDown(ArrowLeft); modifiers=[Shift, Control], action_dispatch=TextToolMessage::MoveCursorLeft { word: true, select: true }), + entry!(KeyDown(ArrowRight); modifiers=[Shift], action_dispatch=TextToolMessage::MoveCursorRight { word: false, select: true }), + entry!(KeyDown(ArrowRight); modifiers=[Shift, Alt], action_dispatch=TextToolMessage::MoveCursorRight { word: true, select: true }), + entry!(KeyDown(ArrowRight); modifiers=[Shift, Control], action_dispatch=TextToolMessage::MoveCursorRight { word: true, select: true }), + entry!(KeyDown(ArrowUp); modifiers=[Shift], action_dispatch=TextToolMessage::MoveCursorUp { select: true }), + entry!(KeyDown(ArrowDown); modifiers=[Shift], action_dispatch=TextToolMessage::MoveCursorDown { select: true }), + entry!(KeyDown(Home); modifiers=[Shift], action_dispatch=TextToolMessage::MoveCursorHome { select: true }), + entry!(KeyDown(End); modifiers=[Shift], action_dispatch=TextToolMessage::MoveCursorEnd { select: true }), + entry!(KeyDown(ArrowLeft); modifiers=[Shift, Command], action_dispatch=TextToolMessage::MoveCursorHome { select: true }), + entry!(KeyDown(ArrowRight); modifiers=[Shift, Command], action_dispatch=TextToolMessage::MoveCursorEnd { select: true }), + entry!(KeyDown(ArrowUp); modifiers=[Shift, Command], action_dispatch=TextToolMessage::MoveCursorTop { select: true }), + entry!(KeyDown(ArrowUp); modifiers=[Shift, Control], action_dispatch=TextToolMessage::MoveCursorTop { select: true }), + entry!(KeyDown(ArrowDown); modifiers=[Shift, Command], action_dispatch=TextToolMessage::MoveCursorBottom { select: true }), + entry!(KeyDown(ArrowDown); modifiers=[Shift, Control], action_dispatch=TextToolMessage::MoveCursorBottom { select: true }), + entry!(KeyDown(Enter); action_dispatch=TextToolMessage::InsertNewline), + entry!(KeyDown(KeyA); modifiers=[Accel], action_dispatch=TextToolMessage::SelectAll), + entry!(KeyDown(Backspace); modifiers=[Alt], action_dispatch=TextToolMessage::DeletePreviousWord), + entry!(KeyDown(Backspace); modifiers=[Control], action_dispatch=TextToolMessage::DeletePreviousWord), + entry!(KeyDown(Backspace); modifiers=[Command], action_dispatch=TextToolMessage::DeleteLine), + entry!(KeyDown(KeyZ); modifiers=[Accel, Shift], canonical, action_dispatch=TextToolMessage::Redo), + entry!(KeyDown(KeyY); modifiers=[Accel], action_dispatch=TextToolMessage::Redo), + entry!(KeyDown(KeyZ); modifiers=[Accel], action_dispatch=TextToolMessage::Undo), // // GradientToolMessage entry!(DoubleClick(MouseButton::Left); action_dispatch=GradientToolMessage::DoubleClick), @@ -463,7 +507,7 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { entry!(KeyDown(Space); modifiers=[Shift], action_dispatch=AnimationMessage::ToggleLivePreview), entry!(KeyDown(Home); modifiers=[Shift], action_dispatch=AnimationMessage::RestartAnimation), ]; - let (mut key_up, mut key_down, mut key_up_no_repeat, mut key_down_no_repeat, mut double_click, mut wheel_scroll, mut pointer_move, mut pointer_shake) = mappings; + let (mut key_up, mut key_down, mut key_up_no_repeat, mut key_down_no_repeat, mut double_click, mut triple_click, mut wheel_scroll, mut pointer_move, mut pointer_shake) = mappings; let sort = |list: &mut KeyMappingEntries| list.0.sort_by(|a, b| b.modifiers.count_ones().cmp(&a.modifiers.count_ones())); // Sort the sublists of `key_up`, `key_down`, `key_up_no_repeat`, and `key_down_no_repeat` @@ -472,10 +516,13 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { sort(sublist); } } - // Sort the sublists of `double_click` + // Sort the sublists of `double_click` and `triple_click` for sublist in &mut double_click { sort(sublist) } + for sublist in &mut triple_click { + sort(sublist) + } // Sort `wheel_scroll` sort(&mut wheel_scroll); // Sort `pointer_move` @@ -489,6 +536,7 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { key_up_no_repeat, key_down_no_repeat, double_click, + triple_click, wheel_scroll, pointer_move, pointer_shake, diff --git a/editor/src/messages/input_mapper/utility_types/macros.rs b/editor/src/messages/input_mapper/utility_types/macros.rs index 9dae86c9a5..6e32fb70ea 100644 --- a/editor/src/messages/input_mapper/utility_types/macros.rs +++ b/editor/src/messages/input_mapper/utility_types/macros.rs @@ -119,6 +119,7 @@ macro_rules! mapping { let mut key_up_no_repeat = KeyMappingEntries::key_array(); let mut key_down_no_repeat = KeyMappingEntries::key_array(); let mut double_click = KeyMappingEntries::mouse_buttons_arrays(); + let mut triple_click = KeyMappingEntries::mouse_buttons_arrays(); let mut wheel_scroll = KeyMappingEntries::new(); let mut pointer_move = KeyMappingEntries::new(); let mut pointer_shake = KeyMappingEntries::new(); @@ -138,6 +139,7 @@ macro_rules! mapping { InputMapperMessage::KeyDownNoRepeat(key) => &mut key_down_no_repeat[key as usize], InputMapperMessage::KeyUpNoRepeat(key) => &mut key_up_no_repeat[key as usize], InputMapperMessage::DoubleClick(key) => &mut double_click[key as usize], + InputMapperMessage::TripleClick(key) => &mut triple_click[key as usize], InputMapperMessage::WheelScroll => &mut wheel_scroll, InputMapperMessage::PointerMove => &mut pointer_move, InputMapperMessage::PointerShake => &mut pointer_shake, @@ -148,7 +150,7 @@ macro_rules! mapping { } )* - (key_up, key_down, key_up_no_repeat, key_down_no_repeat, double_click, wheel_scroll, pointer_move, pointer_shake) + (key_up, key_down, key_up_no_repeat, key_down_no_repeat, double_click, triple_click, wheel_scroll, pointer_move, pointer_shake) }}; } diff --git a/editor/src/messages/input_mapper/utility_types/misc.rs b/editor/src/messages/input_mapper/utility_types/misc.rs index e98df2a22e..681a703efd 100644 --- a/editor/src/messages/input_mapper/utility_types/misc.rs +++ b/editor/src/messages/input_mapper/utility_types/misc.rs @@ -12,6 +12,7 @@ pub struct Mapping { pub key_up_no_repeat: [KeyMappingEntries; NUMBER_OF_KEYS], pub key_down_no_repeat: [KeyMappingEntries; NUMBER_OF_KEYS], pub double_click: [KeyMappingEntries; NUMBER_OF_MOUSE_BUTTONS], + pub triple_click: [KeyMappingEntries; NUMBER_OF_MOUSE_BUTTONS], pub wheel_scroll: KeyMappingEntries, pub pointer_move: KeyMappingEntries, pub pointer_shake: KeyMappingEntries, @@ -36,6 +37,7 @@ impl Mapping { InputMapperMessage::KeyDownNoRepeat(key) => &self.key_down_no_repeat[*key as usize], InputMapperMessage::KeyUpNoRepeat(key) => &self.key_up_no_repeat[*key as usize], InputMapperMessage::DoubleClick(key) => &self.double_click[*key as usize], + InputMapperMessage::TripleClick(key) => &self.triple_click[*key as usize], InputMapperMessage::WheelScroll => &self.wheel_scroll, InputMapperMessage::PointerMove => &self.pointer_move, InputMapperMessage::PointerShake => &self.pointer_shake, diff --git a/editor/src/messages/input_preprocessor/input_preprocessor_message.rs b/editor/src/messages/input_preprocessor/input_preprocessor_message.rs index db7932fdf4..9ea375348a 100644 --- a/editor/src/messages/input_preprocessor/input_preprocessor_message.rs +++ b/editor/src/messages/input_preprocessor/input_preprocessor_message.rs @@ -6,6 +6,7 @@ use crate::messages::prelude::*; #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum InputPreprocessorMessage { DoubleClick { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys }, + TripleClick { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys }, KeyDown { key: Key, key_repeat: bool, modifier_keys: ModifierKeys }, KeyUp { key: Key, key_repeat: bool, modifier_keys: ModifierKeys }, PointerDown { editor_mouse_state: EditorMouseState, modifier_keys: ModifierKeys }, diff --git a/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs b/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs index e9f5847b7b..93e65789a3 100644 --- a/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs +++ b/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs @@ -41,6 +41,23 @@ impl<'a> MessageHandler { + self.update_states_of_modifier_keys(modifier_keys, responses); + + let mouse_state = editor_mouse_state.to_mouse_state(viewport); + self.mouse.position = mouse_state.position; + + for key in mouse_state.mouse_keys { + responses.add(InputMapperMessage::TripleClick(match key { + MouseKeys::LEFT => MouseButton::Left, + MouseKeys::RIGHT => MouseButton::Right, + MouseKeys::MIDDLE => MouseButton::Middle, + MouseKeys::BACK => MouseButton::Back, + MouseKeys::FORWARD => MouseButton::Forward, + _ => unimplemented!(), + })); + } + } InputPreprocessorMessage::KeyDown { key, key_repeat, modifier_keys } => { self.update_states_of_modifier_keys(modifier_keys, responses); self.keyboard.set(key as usize); diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index 628effc89b..3ee30016d2 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -1722,7 +1722,7 @@ impl<'a> MessageHandler> for NodeG let mut nodes = Vec::new(); for node_id in &self.frontend_nodes { let Some(node_bbox) = network_interface.node_bounding_box(node_id, breadcrumb_network_path) else { - log::error!("Could not get bbox for node: {node_id:?}"); + log::warn!("Could not get bbox for node: {node_id:?}"); continue; }; diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 29155ce22c..821602bf33 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -2,6 +2,7 @@ use super::tool_prelude::*; use crate::consts::{COLOR_OVERLAY_BLUE_05, COLOR_OVERLAY_RED, DRAG_THRESHOLD}; +use crate::messages::clipboard::utility_types::ClipboardContent; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; @@ -21,7 +22,6 @@ use graph_craft::application_io::resource::ResourceId; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::choice_type::ChoiceTypeStatic; -use graphene_std::color::SRGBA8; use graphene_std::renderer::Quad; use graphene_std::text::{Font, TextAlign, TypesettingConfig, lines_clipping}; use graphene_std::vector::style::{Fill, FillChoice, FillChoiceUI}; @@ -67,19 +67,41 @@ pub enum TextToolMessage { Abort, WorkingColorChanged, Overlays { context: OverlayContext }, + TimerTick { tick: u64 }, // Tool-specific messages DragStart, DragStop, + DoubleClick, + TripleClick, EditSelected, - Interact, PointerMove { center: Key, lock_ratio: Key }, PointerOutsideViewport { center: Key, lock_ratio: Key }, SelectionChanged, - TextChange { new_text: String, is_left_or_right_click: bool }, - UpdateBounds { new_text: String }, UpdateOptions { options: TextOptionsUpdate }, - RefreshEditingFontData, + KeyboardInput { character: char }, + BackspaceChar, + DeleteChar, + MoveCursorLeft { word: bool, select: bool }, + MoveCursorRight { word: bool, select: bool }, + MoveCursorUp { select: bool }, + MoveCursorDown { select: bool }, + MoveCursorHome { select: bool }, + MoveCursorEnd { select: bool }, + MoveCursorTop { select: bool }, + MoveCursorBottom { select: bool }, + InsertNewline, + CommitText, + Undo, + Redo, + + // Text selection and clipboard + SelectAll, + DeletePreviousWord, + DeleteLine, + Copy, + Cut, + PasteText { text: String }, } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -389,10 +411,38 @@ impl<'a> MessageHandler> for Text DragStart, PointerOutsideViewport, PointerMove, + DoubleClick, ), TextToolFsmState::Editing => actions!(TextToolMessageDiscriminant; DragStart, + DragStop, + PointerMove, Abort, + EditSelected, + KeyboardInput, + BackspaceChar, + DeleteChar, + MoveCursorLeft, + MoveCursorRight, + MoveCursorUp, + MoveCursorDown, + MoveCursorHome, + MoveCursorEnd, + MoveCursorTop, + MoveCursorBottom, + InsertNewline, + CommitText, + Undo, + Redo, + TimerTick, + DoubleClick, + TripleClick, + SelectAll, + DeletePreviousWord, + DeleteLine, + Copy, + Cut, + PasteText, ), TextToolFsmState::Placing | TextToolFsmState::Dragging => actions!(TextToolMessageDiscriminant; DragStop, @@ -439,7 +489,6 @@ enum TextToolFsmState { #[derive(Clone, Debug)] pub struct EditingText { - text: String, font: Font, typesetting: TypesettingConfig, color: Option, @@ -453,6 +502,24 @@ struct ResizingLayer { original_transform: DAffine2, } +#[derive(Clone, Debug, Default, PartialEq)] +struct TextToolHistoryState { + text: String, + cursor_byte_offset: usize, + selection_start_byte_offset: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum TextEditType { + #[default] + None, + InsertChar, + InsertWhitespace, + Delete, + Paste, + InsertNewline, +} + #[derive(Clone, Debug, Default)] struct TextToolData { layer: LayerNodeIdentifier, @@ -468,12 +535,32 @@ struct TextToolData { snap_candidates: Vec, // TODO: Handle multiple layers in the future layer_dragging: Option, + cursor_byte_offset: usize, + selection_start_byte_offset: Option, + cursor_visible: bool, + blink_tick: u64, + dragging_to_select: bool, + history: Vec, + history_index: usize, + last_edit_type: TextEditType, } impl TextToolData { - fn delete_empty_layer(&mut self, fonts: &FontsMessageHandler, responses: &mut VecDeque) -> TextToolFsmState { + fn get_selected_text(&self) -> Option { + if let Some(start) = self.selection_start_byte_offset { + let end = self.cursor_byte_offset; + let min = start.min(end).min(self.new_text.len()); + let max = start.max(end).min(self.new_text.len()); + if min < max { + return Some(self.new_text[min..max].to_string()); + } + } + None + } + + fn delete_empty_layer(&mut self, responses: &mut VecDeque) -> TextToolFsmState { // Remove the editable textbox UI first - self.set_editing(false, fonts, responses); + self.set_editing(false, responses); // Delete the empty text layer and update the graph responses.add(NodeGraphMessage::DeleteNodes { @@ -485,75 +572,115 @@ impl TextToolData { TextToolFsmState::Ready } - /// Set the editing state of the currently modifying layer - fn set_editing(&self, editable: bool, fonts: &FontsMessageHandler, responses: &mut VecDeque) { - if let Some(editing_text) = self.editing_text.as_ref().filter(|_| editable) { - let (align, align_last) = editing_text.typesetting.align.css(); - let font_data = fonts.get_resource_or_queue_load(&editing_text.font, responses).as_ref().to_vec().into(); - responses.add(FrontendMessage::DisplayEditableTextbox { - text: editing_text.text.clone(), - line_height_ratio: editing_text.typesetting.line_height_ratio, - font_size: editing_text.typesetting.font_size, - color: editing_text.color.map_or("#000000".to_string(), |color| SRGBA8::from(color).to_css_hex()), - font_data, - transform: editing_text.transform.to_cols_array(), - max_width: editing_text.typesetting.max_width, - max_height: editing_text.typesetting.max_height, - align: align.to_string(), - align_last: align_last.to_string(), - }); - } else { - // Check if DisplayRemoveEditableTextbox is already in the responses queue - let has_remove_textbox = responses.iter().any(|msg| matches!(msg, Message::Frontend(FrontendMessage::DisplayRemoveEditableTextbox))); - responses.add(FrontendMessage::DisplayRemoveEditableTextbox); + /// Set the editing state of the currently modifying layer. + fn set_editing(&self, editable: bool, responses: &mut VecDeque) { + responses.add(OverlaysMessage::Draw); + responses.add(FrontendMessage::UpdateTextEditingState { is_editing: editable }); + } - if has_remove_textbox { - responses.add(NodeGraphMessage::SelectedNodesSet { nodes: Vec::new() }); - } + fn reset_cursor_blink(&mut self, responses: &mut VecDeque) { + self.cursor_visible = true; + self.blink_tick += 1; + spawn_blink_timer(self.blink_tick, responses); + responses.add(OverlaysMessage::Draw); + } + + /// Deletes the currently selected text and returns true if there was a selection to delete. + fn delete_selection(&mut self) -> bool { + let Some(sel_start) = self.selection_start_byte_offset.take() else { return false }; + let start = sel_start.min(self.cursor_byte_offset); + let end = sel_start.max(self.cursor_byte_offset); + if start == end { + return false; } + self.new_text.replace_range(start..end, ""); + self.cursor_byte_offset = start; + true } - fn load_layer_text_node(&mut self, document: &DocumentMessageHandler, fonts: &FontsMessageHandler) -> Option<()> { + fn commit_history_state(&mut self) { + let state = TextToolHistoryState { + text: self.new_text.clone(), + cursor_byte_offset: self.cursor_byte_offset, + selection_start_byte_offset: self.selection_start_byte_offset, + }; + if let Some(last) = self.history.get_mut(self.history_index) + && last.text == state.text + { + last.cursor_byte_offset = state.cursor_byte_offset; + last.selection_start_byte_offset = state.selection_start_byte_offset; + return; + } + self.history.truncate(self.history_index + 1); + self.history.push(state); + self.history_index = self.history.len() - 1; + } + + fn find_closest_byte_offset(&self, document: &DocumentMessageHandler, mouse_viewport: DVec2, fonts: &FontsMessageHandler, responses: &mut VecDeque) -> Option { + let editing_text = self.editing_text.as_ref()?; + let font_resource = fonts.get_resource_or_queue_load(&editing_text.font, responses); + let layer_to_viewport = document.metadata().transform_to_viewport(self.layer); + if layer_to_viewport.matrix2.determinant() == 0. { + return None; + } + let viewport_to_layer = layer_to_viewport.inverse(); + let click_local = viewport_to_layer.transform_point2(mouse_viewport); + + Some(graphene_std::text::hit_test_position(&self.new_text, &font_resource, editing_text.typesetting, click_local)) + } + + fn load_layer_text_node(&mut self, document: &DocumentMessageHandler, fonts: &FontsMessageHandler, responses: &mut VecDeque, mouse_viewport: Option) -> Option<()> { let transform = document.metadata().transform_to_viewport(self.layer); let color = graph_modification_utils::get_fill_color(self.layer, &document.network_interface).unwrap_or(Color::BLACK); let (text, font, typesetting) = graph_modification_utils::get_text(self.layer, &document.network_interface, fonts, &document.resources)?; self.editing_text = Some(EditingText { - text: text.clone(), font, typesetting, color: Some(color), transform, }); self.new_text.clone_from(text); + if let Some(mouse) = mouse_viewport { + self.cursor_byte_offset = self.find_closest_byte_offset(document, mouse, fonts, responses).unwrap_or(self.new_text.len()); + } else { + self.cursor_byte_offset = self.new_text.len(); + } + self.reset_cursor_blink(responses); Some(()) } - fn start_editing_layer(&mut self, layer: LayerNodeIdentifier, tool_state: TextToolFsmState, document: &DocumentMessageHandler, fonts: &FontsMessageHandler, responses: &mut VecDeque) { + fn start_editing_layer( + &mut self, + layer: LayerNodeIdentifier, + tool_state: TextToolFsmState, + document: &DocumentMessageHandler, + fonts: &FontsMessageHandler, + responses: &mut VecDeque, + mouse_viewport: Option, + ) { if layer == LayerNodeIdentifier::ROOT_PARENT { log::error!("Cannot edit ROOT_PARENT in TextTooLData") } if tool_state == TextToolFsmState::Editing { - self.set_editing(false, fonts, responses); + self.set_editing(false, responses); } self.layer = layer; - if self.load_layer_text_node(document, fonts).is_some() { + if self.load_layer_text_node(document, fonts, responses, mouse_viewport).is_some() { responses.add(DocumentMessage::AddTransaction); - self.set_editing(true, fonts, responses); + self.history.clear(); + self.history_index = 0; + self.commit_history_state(); + + self.set_editing(true, responses); responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![self.layer.to_node()] }); - // Make the rendered text invisible while editing - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(graph_modification_utils::get_text_id(self.layer, &document.network_interface).unwrap(), 1), - input: NodeInput::value(TaggedValue::String("".to_string()), false), - }); - responses.add(NodeGraphMessage::RunDocumentGraph); }; } - fn new_text(&mut self, document: &DocumentMessageHandler, editing_text: EditingText, fonts: &FontsMessageHandler, responses: &mut VecDeque) { + fn new_text(&mut self, document: &DocumentMessageHandler, editing_text: EditingText, responses: &mut VecDeque) { self.new_text = String::new(); responses.add(DocumentMessage::AddTransaction); @@ -577,8 +704,15 @@ impl TextToolData { }); let transform = editing_text.transform; self.editing_text = Some(editing_text); + self.cursor_byte_offset = 0; + self.selection_start_byte_offset = None; + self.reset_cursor_blink(responses); + + self.history.clear(); + self.history_index = 0; + self.commit_history_state(); - self.set_editing(true, fonts, responses); + self.set_editing(true, responses); responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![self.layer.to_node()] }); @@ -601,7 +735,7 @@ impl TextToolData { } fn check_click(document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, fonts: &FontsMessageHandler, responses: &mut VecDeque) -> Option { - let mouse = DVec2::new(input.mouse.position.x, input.mouse.position.y); + let mouse = input.mouse.position; document.metadata().all_layers().filter(|&layer| document.metadata().is_text_layer(layer)).find(|&layer| { let transformed_quad = document.metadata().transform_to_viewport(layer) * text_bounding_box(layer, document, fonts, responses); transformed_quad.contains(mouse) @@ -654,22 +788,45 @@ impl Fsm for TextToolFsmState { let ToolMessage::Text(event) = event else { return self }; match (self, event) { (TextToolFsmState::Editing, TextToolMessage::Overlays { context: mut overlay_context }) => { - // While editing, the text is blanked, so the layer's rendered transform metadata is absent; read the Transform node so the overlay tracks placement - let transform = document - .metadata() - .transform_to_viewport_with_first_transform_node_if_group(tool_data.layer, &document.network_interface) - .to_cols_array(); - responses.add(FrontendMessage::DisplayEditableTextboxTransform { transform }); - if let Some(editing_text) = tool_data.editing_text.as_mut() { + if let Some(editing_text) = tool_data.editing_text.as_ref() { let font_resource = fonts.get_resource_or_queue_load(&editing_text.font, responses); + let layer_to_viewport = if !document.metadata().layer_exists(tool_data.layer) { + document.metadata().document_to_viewport * editing_text.transform + } else { + let t = document + .metadata() + .transform_to_viewport_with_first_transform_node_if_group(tool_data.layer, &document.network_interface); + if t.matrix2.determinant() == 0. { + document.metadata().document_to_viewport * editing_text.transform + } else { + t + } + }; let far = graphene_std::text::bounding_box(&tool_data.new_text, &font_resource, editing_text.typesetting, false); if far.x != 0. && far.y != 0. { let quad = Quad::from_box([DVec2::ZERO, far]); - let transformed_quad = document - .metadata() - .transform_to_viewport_with_first_transform_node_if_group(tool_data.layer, &document.network_interface) - * quad; - overlay_context.quad(transformed_quad, None, Some(fill_color)); + overlay_context.quad(layer_to_viewport * quad, None, Some(fill_color)); + } + + if let Some(selection_start) = tool_data.selection_start_byte_offset { + let start = selection_start.min(tool_data.cursor_byte_offset); + let end = selection_start.max(tool_data.cursor_byte_offset); + if start != end { + let rects = graphene_std::text::selection_rectangles(&tool_data.new_text, &font_resource, editing_text.typesetting, start, end); + for [top_left, bottom_right] in rects { + let sel_quad = Quad::from_box([top_left, bottom_right]); + overlay_context.quad(layer_to_viewport * sel_quad, None, Some("#41414140")); + } + } + } + + if tool_data.cursor_visible { + let cursor_offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + let [top_left, bottom_right] = graphene_std::text::cursor_rect(&tool_data.new_text, &font_resource, editing_text.typesetting, cursor_offset, 0.0); + + let cursor_start = layer_to_viewport.transform_point2(DVec2::new(top_left.x, top_left.y)); + let cursor_end = layer_to_viewport.transform_point2(DVec2::new(top_left.x, bottom_right.y)); + overlay_context.line(cursor_start, cursor_end, Some("#000000"), Some(1.5)); } } @@ -727,12 +884,28 @@ impl Fsm for TextToolFsmState { } (state, TextToolMessage::EditSelected) => { if let Some(layer) = can_edit_selected(document) { - tool_data.start_editing_layer(layer, state, document, fonts, responses); + tool_data.start_editing_layer(layer, state, document, fonts, responses, None); return TextToolFsmState::Editing; } state } + (TextToolFsmState::Editing, TextToolMessage::DragStart) => { + if let Some(clicked_text_layer_path) = TextToolData::check_click(document, input, fonts, responses) + && clicked_text_layer_path == tool_data.layer + { + let click_offset = tool_data.find_closest_byte_offset(document, input.mouse.position, fonts, responses).unwrap_or(tool_data.new_text.len()); + tool_data.cursor_byte_offset = click_offset; + tool_data.selection_start_byte_offset = Some(click_offset); + tool_data.dragging_to_select = true; + tool_data.last_edit_type = TextEditType::None; + tool_data.reset_cursor_blink(responses); + return TextToolFsmState::Editing; + } + + let next_state = self.transition(ToolMessage::Text(TextToolMessage::CommitText), tool_data, transition_data, tool_options, responses); + next_state.transition(ToolMessage::Text(TextToolMessage::DragStart), tool_data, transition_data, tool_options, responses) + } (TextToolFsmState::Ready, TextToolMessage::DragStart) => { tool_data.resize.start(document, input, viewport); tool_data.cached_resize_bounds = [tool_data.resize.viewport_drag_start(document); 2]; @@ -788,6 +961,45 @@ impl Fsm for TextToolFsmState { } TextToolFsmState::Placing } + (TextToolFsmState::Ready, TextToolMessage::DoubleClick) => { + if let Some(clicked_layer) = TextToolData::check_click(document, input, fonts, responses) { + let selected = document.network_interface.selected_nodes(); + let mut all_selected = selected.selected_visible_and_unlocked_layers(&document.network_interface); + let selected = all_selected.find(|&layer| document.metadata().is_text_layer(layer)); + + if selected != Some(clicked_layer) { + responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![clicked_layer.to_node()] }); + } + + tool_data.start_editing_layer(clicked_layer, TextToolFsmState::Ready, document, fonts, responses, Some(input.mouse.position)); + + return TextToolFsmState::Editing; + } + TextToolFsmState::Ready + } + (TextToolFsmState::Editing, TextToolMessage::PointerMove { .. }) => { + if tool_data.dragging_to_select { + let new_offset = tool_data + .find_closest_byte_offset(document, input.mouse.position, fonts, responses) + .unwrap_or(tool_data.cursor_byte_offset); + tool_data.cursor_byte_offset = new_offset; + tool_data.last_edit_type = TextEditType::None; + responses.add(OverlaysMessage::Draw); + } + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::DragStop) => { + if tool_data.dragging_to_select { + if let Some(anchor) = tool_data.selection_start_byte_offset + && anchor == tool_data.cursor_byte_offset + { + tool_data.selection_start_byte_offset = None; + } + tool_data.dragging_to_select = false; + tool_data.reset_cursor_blink(responses); + } + TextToolFsmState::Editing + } (TextToolFsmState::Ready, TextToolMessage::PointerMove { .. }) => { // This ensures the cursor only changes if a layer is selected let selected = document.network_interface.selected_nodes(); @@ -915,11 +1127,38 @@ impl Fsm for TextToolFsmState { } TextToolFsmState::ResizingBounds } - (_, TextToolMessage::PointerMove { .. }) => { - tool_data.resize.snap_manager.preview_draw(&SnapData::new(document, input, viewport), input.mouse.position); - responses.add(OverlaysMessage::Draw); - self + (TextToolFsmState::Editing, TextToolMessage::DoubleClick) => { + let offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + let prefix = &tool_data.new_text[..offset]; + let suffix = &tool_data.new_text[offset..]; + + let word_start = prefix.rfind(|c: char| c.is_whitespace()).map(|i| i + prefix[i..].chars().next().unwrap().len_utf8()).unwrap_or(0); + let word_end = suffix.find(|c: char| c.is_whitespace()).map(|i| offset + i).unwrap_or(tool_data.new_text.len()); + + if word_start != word_end { + tool_data.selection_start_byte_offset = Some(word_start); + tool_data.cursor_byte_offset = word_end; + tool_data.last_edit_type = TextEditType::None; + tool_data.reset_cursor_blink(responses); + } + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::TripleClick) => { + let offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + let prefix = &tool_data.new_text[..offset]; + let suffix = &tool_data.new_text[offset..]; + + let line_start = prefix.rfind('\n').map(|i| i + 1).unwrap_or(0); + let line_end = suffix.find('\n').map(|i| offset + i).unwrap_or(tool_data.new_text.len()); + + if line_start != line_end { + tool_data.selection_start_byte_offset = Some(line_start); + tool_data.cursor_byte_offset = line_end; + tool_data.last_edit_type = TextEditType::None; + tool_data.reset_cursor_blink(responses); + } + TextToolFsmState::Editing } (TextToolFsmState::Placing, TextToolMessage::PointerOutsideViewport { .. }) => { // Auto-panning setup @@ -967,14 +1206,13 @@ impl Fsm for TextToolFsmState { // Check if the user has clicked (no dragging) on some existing text if !has_dragged && let Some(clicked_text_layer_path) = TextToolData::check_click(document, input, fonts, responses) { - tool_data.start_editing_layer(clicked_text_layer_path, self, document, fonts, responses); + tool_data.start_editing_layer(clicked_text_layer_path, self, document, fonts, responses, Some(input.mouse.position)); return TextToolFsmState::Editing; } // Otherwise create some new text. The window-aligned transform is in viewport space, so the editing overlay (a screen-space CSS matrix) carries the zoom. let constraint_size = has_dragged.then_some((start - end).abs() / viewport_zoom(document)); let editing_text = EditingText { - text: String::new(), transform: window_aligned_transform(document, start, DVec2::ONE), typesetting: TypesettingConfig { font_size: tool_options.font_size, @@ -988,7 +1226,7 @@ impl Fsm for TextToolFsmState { font: Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()), color: tool_options.fill.active_color(), }; - tool_data.new_text(document, editing_text, fonts, responses); + tool_data.new_text(document, editing_text, responses); TextToolFsmState::Editing } (TextToolFsmState::Dragging, TextToolMessage::DragStop) => { @@ -1003,50 +1241,408 @@ impl Fsm for TextToolFsmState { } if drag_too_small && let Some(layer_info) = &tool_data.layer_dragging { - tool_data.start_editing_layer(layer_info.id, self, document, fonts, responses); + tool_data.start_editing_layer(layer_info.id, self, document, fonts, responses, Some(input.mouse.position)); return TextToolFsmState::Editing; } tool_data.layer_dragging.take(); TextToolFsmState::Ready } - (TextToolFsmState::Editing, TextToolMessage::RefreshEditingFontData) => { - let font = Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()); - let font_resource = fonts.get_resource_or_queue_load(&font, responses); - responses.add(FrontendMessage::DisplayEditableTextboxUpdateFontData { - font_data: font_resource.as_ref().to_vec().into(), - }); + (TextToolFsmState::Editing, TextToolMessage::KeyboardInput { character }) => { + let current_edit_type = if character.is_whitespace() { TextEditType::InsertWhitespace } else { TextEditType::InsertChar }; + if tool_data.last_edit_type != current_edit_type { + tool_data.commit_history_state(); + } + tool_data.last_edit_type = current_edit_type; + + tool_data.delete_selection(); + // Insert the character at the cursor position. + let offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + tool_data.new_text.insert(offset, character); + tool_data.cursor_byte_offset = offset + character.len_utf8(); + + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(text_node_id, 1), + input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::BackspaceChar) => { + if tool_data.last_edit_type != TextEditType::Delete { + tool_data.commit_history_state(); + } + tool_data.last_edit_type = TextEditType::Delete; + + if !tool_data.delete_selection() { + let offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + if offset > 0 { + // Find the start of the previous UTF-8 character. + let prev = tool_data.new_text[..offset].char_indices().next_back().map(|(i, _)| i).unwrap_or(0); + tool_data.new_text.remove(prev); + tool_data.cursor_byte_offset = prev; + } + } + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(text_node_id, 1), + input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + tool_data.reset_cursor_blink(responses); TextToolFsmState::Editing } - (TextToolFsmState::Editing, TextToolMessage::TextChange { new_text, is_left_or_right_click }) => { - tool_data.new_text = new_text; + (TextToolFsmState::Editing, TextToolMessage::DeleteChar) => { + if tool_data.last_edit_type != TextEditType::Delete { + tool_data.commit_history_state(); + } + tool_data.last_edit_type = TextEditType::Delete; - if !is_left_or_right_click { - tool_data.set_editing(false, fonts, responses); + if !tool_data.delete_selection() { + let offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + if offset < tool_data.new_text.len() { + tool_data.new_text.remove(offset); + } + } + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface).unwrap(), 1), + input_connector: InputConnector::node(text_node_id, 1), input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), }); responses.add(NodeGraphMessage::RunDocumentGraph); + } + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::MoveCursorLeft { word, select }) => { + let old_offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + if !select { + tool_data.selection_start_byte_offset = None; + } else if tool_data.selection_start_byte_offset.is_none() { + tool_data.selection_start_byte_offset = Some(old_offset); + } + let offset = old_offset; + if word { + let prefix = &tool_data.new_text[..offset]; + let trimmed = prefix.trim_end(); + let word_end = trimmed.rfind(|c: char| c.is_whitespace()).map(|i| i + trimmed[i..].chars().next().unwrap().len_utf8()).unwrap_or(0); + tool_data.cursor_byte_offset = word_end; + } else if offset > 0 { + let prev = tool_data.new_text[..offset].char_indices().next_back().map(|(i, _)| i).unwrap_or(0); + tool_data.cursor_byte_offset = prev; + } + tool_data.last_edit_type = TextEditType::None; + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::MoveCursorRight { word, select }) => { + let old_offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + if !select { + tool_data.selection_start_byte_offset = None; + } else if tool_data.selection_start_byte_offset.is_none() { + tool_data.selection_start_byte_offset = Some(old_offset); + } + let offset = old_offset; + if word { + let suffix = &tool_data.new_text[offset..]; + let word_end_rel = suffix + .find(|c: char| c.is_whitespace()) + .map(|i| i + suffix[i..].find(|c: char| !c.is_whitespace()).unwrap_or(suffix.len() - i)) + .unwrap_or(suffix.len()); + tool_data.cursor_byte_offset = offset + word_end_rel; + } else if offset < tool_data.new_text.len() { + let next_char = tool_data.new_text[offset..].chars().next().map(|c| c.len_utf8()).unwrap_or(0); + tool_data.cursor_byte_offset = offset + next_char; + } + tool_data.last_edit_type = TextEditType::None; + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, msg @ (TextToolMessage::MoveCursorUp { .. } | TextToolMessage::MoveCursorDown { .. })) => { + let old_offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + let select = matches!(msg, TextToolMessage::MoveCursorUp { select: true } | TextToolMessage::MoveCursorDown { select: true }); + if !select { + tool_data.selection_start_byte_offset = None; + } else if tool_data.selection_start_byte_offset.is_none() { + tool_data.selection_start_byte_offset = Some(old_offset); + } + let offset = old_offset; - TextToolFsmState::Ready - } else { - if tool_data.new_text.is_empty() { - return tool_data.delete_empty_layer(fonts, responses); + if let Some(editing_text) = tool_data.editing_text.as_ref() { + let font_resource = fonts.get_resource_or_queue_load(&editing_text.font, responses); + let [top_left, bottom_right] = graphene_std::text::cursor_rect(&tool_data.new_text, &font_resource, editing_text.typesetting, offset, 0.0); + let current_x = top_left.x; + let current_y = (top_left.y + bottom_right.y) / 2.0; + + let line_height = editing_text.typesetting.font_size * editing_text.typesetting.line_height_ratio; + let target_y = if matches!(msg, TextToolMessage::MoveCursorUp { .. }) { + current_y - line_height + } else { + current_y + line_height + }; + + let new_offset = graphene_std::text::hit_test_position(&tool_data.new_text, &font_resource, editing_text.typesetting, DVec2::new(current_x, target_y)); + tool_data.cursor_byte_offset = new_offset; + } + tool_data.last_edit_type = TextEditType::None; + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::MoveCursorHome { select }) => { + let old_offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + if !select { + tool_data.selection_start_byte_offset = None; + } else if tool_data.selection_start_byte_offset.is_none() { + tool_data.selection_start_byte_offset = Some(old_offset); + } + let line_start = tool_data.new_text[..old_offset].rfind('\n').map(|i| i + 1).unwrap_or(0); + tool_data.cursor_byte_offset = line_start; + tool_data.last_edit_type = TextEditType::None; + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::MoveCursorEnd { select }) => { + let old_offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + if !select { + tool_data.selection_start_byte_offset = None; + } else if tool_data.selection_start_byte_offset.is_none() { + tool_data.selection_start_byte_offset = Some(old_offset); + } + let line_end = tool_data.new_text[old_offset..].find('\n').map(|i| old_offset + i).unwrap_or(tool_data.new_text.len()); + tool_data.cursor_byte_offset = line_end; + tool_data.last_edit_type = TextEditType::None; + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::MoveCursorTop { select }) => { + if !select { + tool_data.selection_start_byte_offset = None; + } else if tool_data.selection_start_byte_offset.is_none() { + tool_data.selection_start_byte_offset = Some(tool_data.cursor_byte_offset.min(tool_data.new_text.len())); + } + tool_data.cursor_byte_offset = 0; + tool_data.last_edit_type = TextEditType::None; + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::MoveCursorBottom { select }) => { + if !select { + tool_data.selection_start_byte_offset = None; + } else if tool_data.selection_start_byte_offset.is_none() { + tool_data.selection_start_byte_offset = Some(tool_data.cursor_byte_offset.min(tool_data.new_text.len())); + } + tool_data.cursor_byte_offset = tool_data.new_text.len(); + tool_data.last_edit_type = TextEditType::None; + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::SelectAll) => { + tool_data.selection_start_byte_offset = Some(0); + tool_data.cursor_byte_offset = tool_data.new_text.len(); + tool_data.last_edit_type = TextEditType::None; + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::Copy) => { + if let Some(selected_text) = tool_data.get_selected_text() { + responses.add(ClipboardMessage::Write { + content: ClipboardContent::Text(selected_text), + }); + } + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::Cut) => { + if let Some(selected_text) = tool_data.get_selected_text() { + responses.add(ClipboardMessage::Write { + content: ClipboardContent::Text(selected_text), + }); + if tool_data.last_edit_type != TextEditType::Delete { + tool_data.commit_history_state(); } + tool_data.last_edit_type = TextEditType::Delete; + tool_data.delete_selection(); + + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(text_node_id, 1), + input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + tool_data.reset_cursor_blink(responses); + } + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::PasteText { text }) => { + if tool_data.last_edit_type != TextEditType::Paste { + tool_data.commit_history_state(); + } + tool_data.last_edit_type = TextEditType::Paste; - responses.add(FrontendMessage::TriggerTextCommit); + tool_data.delete_selection(); + let offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + tool_data.new_text.insert_str(offset, &text); + tool_data.cursor_byte_offset = offset + text.len(); - TextToolFsmState::Editing + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(text_node_id, 1), + input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); } + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing } - (TextToolFsmState::Editing, TextToolMessage::UpdateBounds { new_text }) => { - tool_data.new_text = new_text; - responses.add(OverlaysMessage::Draw); + (TextToolFsmState::Editing, TextToolMessage::DeletePreviousWord) => { + if tool_data.last_edit_type != TextEditType::Delete { + tool_data.commit_history_state(); + } + tool_data.last_edit_type = TextEditType::Delete; + + if !tool_data.delete_selection() { + let offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + if offset > 0 { + let prefix = &tool_data.new_text[..offset]; + let trimmed_len = prefix.trim_end().len(); + let search_end = trimmed_len; + let prev_word_start = prefix[..search_end] + .rfind(|c: char| c.is_whitespace()) + .map(|i| i + prefix[i..].chars().next().unwrap().len_utf8()) + .unwrap_or(0); + + tool_data.new_text.replace_range(prev_word_start..offset, ""); + tool_data.cursor_byte_offset = prev_word_start; + } + } + + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(text_node_id, 1), + input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::DeleteLine) => { + if tool_data.last_edit_type != TextEditType::Delete { + tool_data.commit_history_state(); + } + tool_data.last_edit_type = TextEditType::Delete; + + if !tool_data.delete_selection() { + let offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + if offset > 0 { + let start = tool_data.new_text[..offset].rfind('\n').map(|i| i + 1).unwrap_or(0); + if start == offset { + tool_data.new_text.replace_range((start - 1)..offset, ""); + tool_data.cursor_byte_offset = start - 1; + } else { + tool_data.new_text.replace_range(start..offset, ""); + tool_data.cursor_byte_offset = start; + } + } + } + + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(text_node_id, 1), + input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::InsertNewline) => { + if tool_data.last_edit_type != TextEditType::InsertNewline { + tool_data.commit_history_state(); + } + tool_data.last_edit_type = TextEditType::InsertNewline; + + tool_data.delete_selection(); + let offset = tool_data.cursor_byte_offset.min(tool_data.new_text.len()); + tool_data.new_text.insert(offset, '\n'); + tool_data.cursor_byte_offset = offset + 1; + + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(text_node_id, 1), + input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + tool_data.reset_cursor_blink(responses); + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::Undo) => { + tool_data.commit_history_state(); + if tool_data.history_index > 0 { + tool_data.history_index -= 1; + let state = &tool_data.history[tool_data.history_index]; + tool_data.new_text.clone_from(&state.text); + tool_data.cursor_byte_offset = state.cursor_byte_offset; + tool_data.selection_start_byte_offset = state.selection_start_byte_offset; + + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(text_node_id, 1), + input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + tool_data.reset_cursor_blink(responses); + } + tool_data.last_edit_type = TextEditType::None; + TextToolFsmState::Editing + } + (TextToolFsmState::Editing, TextToolMessage::Redo) => { + if tool_data.history_index + 1 < tool_data.history.len() { + tool_data.history_index += 1; + let state = &tool_data.history[tool_data.history_index]; + tool_data.new_text.clone_from(&state.text); + tool_data.cursor_byte_offset = state.cursor_byte_offset; + tool_data.selection_start_byte_offset = state.selection_start_byte_offset; + + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(text_node_id, 1), + input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + tool_data.reset_cursor_blink(responses); + } + tool_data.last_edit_type = TextEditType::None; TextToolFsmState::Editing } + (TextToolFsmState::Editing, TextToolMessage::CommitText) => { + if tool_data.new_text.is_empty() { + return tool_data.delete_empty_layer(responses); + } + + tool_data.set_editing(false, responses); + + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(text_node_id, 1), + input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } else { + log::warn!("Text node not found for layer {:?}, edits were not committed", tool_data.layer); + } + + TextToolFsmState::Ready + } (_, TextToolMessage::WorkingColorChanged) => { responses.add(TextToolMessage::UpdateOptions { options: TextOptionsUpdate::WorkingColorsChanged, @@ -1055,10 +1651,26 @@ impl Fsm for TextToolFsmState { } (TextToolFsmState::Editing, TextToolMessage::Abort) => { if tool_data.new_text.is_empty() { - return tool_data.delete_empty_layer(fonts, responses); + return tool_data.delete_empty_layer(responses); } - responses.add(FrontendMessage::TriggerTextCommit); + tool_data.set_editing(false, responses); + if let Some(text_node_id) = graph_modification_utils::get_text_id(tool_data.layer, &document.network_interface) { + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(text_node_id, 1), + input: NodeInput::value(TaggedValue::String(tool_data.new_text.clone()), false), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } + TextToolFsmState::Ready + } + (TextToolFsmState::Editing, TextToolMessage::TimerTick { tick }) => { + if tick == tool_data.blink_tick { + tool_data.cursor_visible = !tool_data.cursor_visible; + tool_data.blink_tick += 1; + spawn_blink_timer(tool_data.blink_tick, responses); + responses.add(OverlaysMessage::Draw); + } TextToolFsmState::Editing } (state, TextToolMessage::Abort) => { @@ -1118,3 +1730,23 @@ impl Fsm for TextToolFsmState { responses.add(FrontendMessage::UpdateMouseCursor { cursor }); } } + +fn spawn_blink_timer(tick: u64, responses: &mut VecDeque) { + responses.add(async move { + #[cfg(target_family = "wasm")] + { + let mut cb = |resolve: js_sys::Function, _reject| { + if let Some(window) = web_sys::window() { + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 500); + } + }; + let promise = js_sys::Promise::new(&mut cb); + wasm_bindgen_futures::JsFuture::from(promise).await.unwrap(); + } + #[cfg(not(target_family = "wasm"))] + { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + Message::Tool(ToolMessage::Text(TextToolMessage::TimerTick { tick })) + }); +} diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 5044490212..34118d4332 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -12,10 +12,8 @@ import type { AppWindowStore } from "/src/stores/app-window"; import type { DocumentStore } from "/src/stores/document"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; - import type { MessageBody } from "/src/subscriptions-router"; import { fillChoiceUIColor, createSRgba8 } from "/src/utility-functions/colors"; import { pasteFile } from "/src/utility-functions/files"; - import { textInputCleanup } from "/src/utility-functions/keyboard-entry"; import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization"; import { setupViewportResizeObserver, hasFirstArtworkBeenReceived, markFirstArtworkReceived } from "/src/utility-functions/viewports"; import type { EditorWrapper, MenuDirection, MouseCursorIcon, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -30,11 +28,6 @@ const appWindow = getContext("appWindow"); const document = getContext("document"); - // Interactive text editing - let textInput: undefined | HTMLDivElement = undefined; - let showTextInput: boolean; - let textInputMatrix: [number, number, number, number, number, number]; - // Scrollbars let scrollbarPos = { x: 0.5, y: 0.5 }; let scrollbarSize = { x: 0.5, y: 0.5 }; @@ -86,7 +79,6 @@ let removeUpdatePixelRatio: (() => void) | undefined; let viewportResizeObserver: ResizeObserver | undefined; let cleanupViewportResizeObserver: (() => void) | undefined; - let addedFontFaces: FontFace[] = []; // Dimension is rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing $: canvasWidthRoundedToEven = canvasWidth && (canvasWidth % 2 === 1 ? canvasWidth + 1 : canvasWidth); @@ -348,83 +340,6 @@ canvasCursor = cursorString; } - function preventTextEditingScroll(e: Event) { - if (!(e.target instanceof HTMLElement)) return; - e.target.scrollTop = 0; - e.target.scrollLeft = 0; - } - - // Text entry - export function triggerTextCommit() { - if (!textInput) return; - const textCleaned = textInputCleanup(textInput.innerText); - editor.onChangeText(textCleaned, false); - } - - export async function displayEditableTextbox(data: MessageBody<"DisplayEditableTextbox">) { - showTextInput = true; - - await tick(); - - if (!textInput) return; - - // eslint-disable-next-line svelte/no-dom-manipulating - if (data.text === "") textInput.textContent = ""; - // eslint-disable-next-line svelte/no-dom-manipulating - else textInput.textContent = `${data.text}\n`; - - // Make it so `maxHeight` is a multiple of `lineHeight` - const lineHeight = data.lineHeightRatio * data.fontSize; - let height = data.maxHeight === undefined ? "auto" : `${Math.floor(data.maxHeight / lineHeight) * lineHeight}px`; - - textInput.contentEditable = "true"; - textInput.style.transformOrigin = "0 0"; - textInput.style.width = data.maxWidth ? `${data.maxWidth}px` : "max-content"; - textInput.style.height = height; - textInput.style.lineHeight = `${data.lineHeightRatio}`; - textInput.style.fontSize = `${data.fontSize}px`; - textInput.style.color = data.color; - textInput.style.textAlign = data.align; - textInput.style.textAlignLast = data.alignLast; - - textInput.oninput = () => { - if (!textInput) return; - editor.updateBounds(textInputCleanup(textInput.innerText)); - }; - - textInputMatrix = data.transform; - - if (data.fontData.length > 0 && data.fontData.buffer instanceof ArrayBuffer) { - const fontView = new Uint8Array(data.fontData.buffer, data.fontData.byteOffset, data.fontData.byteLength); - const face = new FontFace("text-font", fontView); - window.document.fonts.add(face); - addedFontFaces.push(face); - textInput.style.fontFamily = "text-font"; - } - - // Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060 - - const range = window.document.createRange(); - range.selectNodeContents(textInput); - - const selection = window.getSelection(); - if (selection) { - selection.removeAllRanges(); - selection.addRange(range); - } - - textInput.focus(); - textInput.click(); - - // Sends the text input element used for interactively editing with the text tool in a custom event - window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: textInput })); - } - - export function displayRemoveEditableTextbox() { - window.dispatchEvent(new CustomEvent("modifyinputfield", { detail: undefined })); - showTextInput = false; - } - function updateViewportInfo() { if (!viewport) return; // Resize the canvas @@ -523,34 +438,8 @@ }); // Text entry - subscriptions.subscribeFrontendMessage("TriggerTextCommit", async () => { - await tick(); - - triggerTextCommit(); - }); - subscriptions.subscribeFrontendMessage("DisplayEditableTextbox", async (data) => { - await tick(); - - displayEditableTextbox(data); - }); - subscriptions.subscribeFrontendMessage("DisplayEditableTextboxUpdateFontData", async (data) => { - await tick(); - - if (textInput && data.fontData.length > 0 && data.fontData.buffer instanceof ArrayBuffer) { - const fontView = new Uint8Array(data.fontData.buffer, data.fontData.byteOffset, data.fontData.byteLength); - const face = new FontFace("text-font", fontView); - window.document.fonts.add(face); - addedFontFaces.push(face); - textInput.style.fontFamily = "text-font"; - } - }); - subscriptions.subscribeFrontendMessage("DisplayEditableTextboxTransform", async (data) => { - textInputMatrix = data.transform; - }); - subscriptions.subscribeFrontendMessage("DisplayRemoveEditableTextbox", async () => { - await tick(); - - displayRemoveEditableTextbox(); + subscriptions.subscribeFrontendMessage("UpdateTextEditingState", async (data) => { + window.dispatchEvent(new CustomEvent("updateTextEditingState", { detail: data.isEditing })); }); // Setup ResizeObserver for pixel-perfect viewport tracking with physical dimensions @@ -568,7 +457,6 @@ cleanupViewportResizeObserver?.(); viewportResizeObserver?.disconnect(); removeUpdatePixelRatio?.(); - addedFontFaces.forEach((face) => window.document.fonts.delete(face)); subscriptions.unsubscribeFrontendMessage("UpdateDocumentArtwork"); subscriptions.unsubscribeFrontendMessage("UpdateEyedropperSamplingState"); @@ -576,11 +464,7 @@ subscriptions.unsubscribeFrontendMessage("UpdateDocumentScrollbars"); subscriptions.unsubscribeFrontendMessage("UpdateDocumentRulers"); subscriptions.unsubscribeFrontendMessage("UpdateMouseCursor"); - subscriptions.unsubscribeFrontendMessage("TriggerTextCommit"); - subscriptions.unsubscribeFrontendMessage("DisplayEditableTextbox"); - subscriptions.unsubscribeFrontendMessage("DisplayEditableTextboxUpdateFontData"); - subscriptions.unsubscribeFrontendMessage("DisplayEditableTextboxTransform"); - subscriptions.unsubscribeFrontendMessage("DisplayRemoveEditableTextbox"); + subscriptions.unsubscribeFrontendMessage("UpdateTextEditingState"); }); @@ -702,11 +586,6 @@ {@html artworkSvg} {/if} -
- {#if showTextInput} -
- {/if} -
{#if !$appWindow.viewportHolePunch} onMouseDown(e) }, { target: window, eventName: "mouseup", action: (e: MouseEvent) => editorWrapper && onPotentialDoubleClick(e, editorWrapper) }, { target: window, eventName: "wheel", action: (e: WheelEvent) => editorWrapper && onWheelScroll(e, editorWrapper), options: { passive: false } }, - { target: window, eventName: "modifyinputfield", action: (e: CustomEvent) => onModifyInputField(e) }, { target: window, eventName: "focusout", action: () => onFocusOut() }, { target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) }, { target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() }, @@ -59,6 +59,7 @@ let documentStore: DocumentStore | undefined = undefined; export function createInputManager(subscriptions: SubscriptionsRouter, editor: EditorWrapper, dialog: DialogStore, portfolio: PortfolioStore, doc: DocumentStore) { destroyInputManager(); + initInput(); subscriptionsRouter = subscriptions; editorWrapper = editor; @@ -86,6 +87,8 @@ export function createInputManager(subscriptions: SubscriptionsRouter, editor: E // Return the destructor export function destroyInputManager() { + destroyInput(); + const subscriptions = subscriptionsRouter; if (!subscriptions) return; diff --git a/frontend/src/utility-functions/input.ts b/frontend/src/utility-functions/input.ts index e0642075ff..5478b2c9e3 100644 --- a/frontend/src/utility-functions/input.ts +++ b/frontend/src/utility-functions/input.ts @@ -4,7 +4,7 @@ import type { DocumentStore } from "/src/stores/document"; import { toggleFullscreen } from "/src/stores/fullscreen"; import type { PortfolioStore } from "/src/stores/portfolio"; import { pasteFile } from "/src/utility-functions/files"; -import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "/src/utility-functions/keyboard-entry"; +import { makeKeyboardModifiersBitfield, getLocalizedScanCode } from "/src/utility-functions/keyboard-entry"; import { operatingSystem } from "/src/utility-functions/platform"; import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -15,9 +15,24 @@ const BUTTON_BACK = 3; const BUTTON_FORWARD = 4; let viewportPointerInteractionOngoing = false; -let textToolInteractiveInputElement: HTMLDivElement | undefined = undefined; let canvasFocused = true; let inPointerLock = false; +let isEditingText = false; + +function handleUpdateTextEditingState(e: Event) { + if (e instanceof CustomEvent) { + isEditingText = Boolean(e.detail); + } +} + +export function initInput() { + window.addEventListener("updateTextEditingState", handleUpdateTextEditingState); +} + +export function destroyInput() { + window.removeEventListener("updateTextEditingState", handleUpdateTextEditingState); +} + let lastShakeTime = 0; const shakeSamples: { x: number; y: number; time: number }[] = []; @@ -86,6 +101,11 @@ export async function onKeyDown(e: KeyboardEvent, editor: EditorWrapper, dialogS if (await shouldRedirectKeyboardEventToBackend(e, dialogStore)) { e.preventDefault(); const modifiers = makeKeyboardModifiersBitfield(e); + if (isEditingText && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + editor.onTextInput(e.key); + return; + } + editor.onKeyDown(key, modifiers, e.repeat); return; } @@ -133,7 +153,6 @@ export function onPointerDown(e: PointerEvent, editor: EditorWrapper, dialogStor const isTargetingCanvas = !inFloatingMenu && e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); const inDialog = e.target instanceof Element && e.target.closest("[data-dialog] [data-floating-menu-content]"); const inContextMenu = e.target instanceof Element && e.target.closest("[data-context-menu]"); - const inTextInput = e.target === textToolInteractiveInputElement; if (get(dialogStore).visible && !inDialog) { editor.onDialogDismiss(); @@ -141,13 +160,8 @@ export function onPointerDown(e: PointerEvent, editor: EditorWrapper, dialogStor e.stopPropagation(); } - if (!inTextInput && !inContextMenu) { - if (textToolInteractiveInputElement) { - const isLeftOrRightClick = e.button === BUTTON_RIGHT || e.button === BUTTON_LEFT; - editor.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText), isLeftOrRightClick); - } else { - viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element; - } + if (!inContextMenu) { + viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element; } if (viewportPointerInteractionOngoing && isTargetingCanvas instanceof Element) { @@ -167,8 +181,6 @@ export function onPointerUp(e: PointerEvent, editor: EditorWrapper) { if (!e.buttons) viewportPointerInteractionOngoing = false; - if (textToolInteractiveInputElement) return; - const modifiers = makeKeyboardModifiersBitfield(e); editor.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers); } @@ -176,15 +188,12 @@ export function onPointerUp(e: PointerEvent, editor: EditorWrapper) { // Mouse events export function onPotentialDoubleClick(e: MouseEvent, editor: EditorWrapper) { - if (textToolInteractiveInputElement || inPointerLock) return; + if (inPointerLock) return; // Allow only events within the viewport or node graph boundaries const isTargetingCanvas = e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); if (!(isTargetingCanvas instanceof Element)) return; - // Allow only repeated increments of double-clicks (not 1, 3, 5, etc.) - if (e.detail % 2 == 1) return; - // `e.buttons` is always 0 in the `mouseup` event, so we have to convert from `e.button` instead let buttons = 1; if (e.button === BUTTON_LEFT) buttons = 1; // Left @@ -194,7 +203,12 @@ export function onPotentialDoubleClick(e: MouseEvent, editor: EditorWrapper) { if (e.button === BUTTON_FORWARD) buttons = 16; // Forward const modifiers = makeKeyboardModifiersBitfield(e); - editor.onDoubleClick(e.clientX, e.clientY, buttons, modifiers); + + if (e.detail === 2) { + editor.onDoubleClick(e.clientX, e.clientY, buttons, modifiers); + } else if (e.detail === 3) { + editor.onTripleClick(e.clientX, e.clientY, buttons, modifiers); + } } export function onMouseDown(e: MouseEvent) { @@ -203,7 +217,7 @@ export function onMouseDown(e: MouseEvent) { } export function onContextMenu(e: MouseEvent) { - if (!targetIsTextField(e.target || undefined) && e.target !== textToolInteractiveInputElement) { + if (!targetIsTextField(e.target || undefined)) { e.preventDefault(); } } @@ -237,12 +251,6 @@ export function onWheelScroll(e: WheelEvent, editor: EditorWrapper) { } } -// Receives a custom event dispatched when the user begins interactively editing with the text tool. -// We keep a copy of the text input element to check against when it's active for text entry. -export function onModifyInputField(e: CustomEvent) { - textToolInteractiveInputElement = e.detail; -} - // Window events export async function onBeforeUnload(e: BeforeUnloadEvent, editor: EditorWrapper, portfolioStore: PortfolioStore) { diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 81843a6f6d..2ad97d0799 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -623,6 +623,17 @@ impl EditorWrapper { self.dispatch(message); } + /// Mouse triple clicked + #[wasm_bindgen(js_name = onTripleClick)] + pub fn on_triple_click(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) { + let editor_mouse_state = EditorMouseState::from_keys_and_editor_position(mouse_keys, (x, y).into()); + + let modifier_keys = ModifierKeys::from_bits(modifiers).expect("Invalid modifier keys"); + + let message = InputPreprocessorMessage::TripleClick { editor_mouse_state, modifier_keys }; + self.dispatch(message); + } + /// A keyboard button depressed within screenspace the bounds of the viewport #[wasm_bindgen(js_name = onKeyDown)] pub fn on_key_down(&self, name: String, modifiers: u8, key_repeat: bool) { @@ -647,12 +658,13 @@ impl EditorWrapper { self.dispatch(message); } - /// A text box was committed - #[wasm_bindgen(js_name = onChangeText)] - pub fn on_change_text(&self, new_text: String, is_left_or_right_click: bool) -> Result<(), JsValue> { - let message = TextToolMessage::TextChange { new_text, is_left_or_right_click }; - self.dispatch(message); - + /// A printable character was typed while editing text + #[wasm_bindgen(js_name = onTextInput)] + pub fn on_text_input(&self, character: String) -> Result<(), JsValue> { + if let Some(c) = character.chars().next() { + let message = TextToolMessage::KeyboardInput { character: c }; + self.dispatch(message); + } Ok(()) } @@ -663,15 +675,6 @@ impl EditorWrapper { self.dispatch(message); } - /// A text box was changed - #[wasm_bindgen(js_name = updateBounds)] - pub fn update_bounds(&self, new_text: String) -> Result<(), JsValue> { - let message = TextToolMessage::UpdateBounds { new_text }; - self.dispatch(message); - - Ok(()) - } - /// Update primary color from sRGB bytes (the wire format at the JS boundary). #[wasm_bindgen(js_name = updatePrimaryColor)] pub fn update_primary_color(&self, color: SRGBA8) { diff --git a/node-graph/nodes/text/src/text_context.rs b/node-graph/nodes/text/src/text_context.rs index c582035357..bfb3dbde89 100644 --- a/node-graph/nodes/text/src/text_context.rs +++ b/node-graph/nodes/text/src/text_context.rs @@ -4,7 +4,9 @@ use core::cell::RefCell; use core_types::list::List; use glam::DVec2; use graphene_resource::{Resource, ResourceHash}; +use parley::editing::{Cursor, Selection}; use parley::fontique::{Blob, FamilyId, FontInfo}; +use parley::layout::Affinity; use parley::{AlignmentOptions, FontContext, GlyphRun, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty}; use std::collections::HashMap; use vector_types::Vector; @@ -186,4 +188,57 @@ impl TextContext { let bounds = self.bounding_box(text, font, typesetting, true); max_height < bounds.y } + + /// Returns the closest byte offset for a given X/Y position within the text layout. + pub fn hit_test_position(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig, position: DVec2) -> usize { + let Some(layout) = self.layout_text(text, font, typesetting) else { + return 0; + }; + + if !position.x.is_finite() || !position.y.is_finite() { + return 0; + } + + let mut clamped_y = position.y as f32; + let height = layout.height(); + if height > 0.0 { + if clamped_y < 0.0 { + clamped_y = 0.0; + } else if clamped_y >= height { + clamped_y = height - 0.001; + } + } else { + clamped_y = 0.0; + } + + let cursor = Cursor::from_point(&layout, position.x as f32, clamped_y); + cursor.index() + } + + /// Returns a list of bounding boxes for the selected text range. + pub fn selection_rectangles(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig, start: usize, end: usize) -> Vec<[DVec2; 2]> { + let Some(layout) = self.layout_text(text, font, typesetting) else { + return Vec::new(); + }; + + let anchor = Cursor::from_byte_index(&layout, start, Affinity::Downstream); + let focus = Cursor::from_byte_index(&layout, end, Affinity::Downstream); + let selection = Selection::new(anchor, focus); + + let rects = selection.geometry(&layout); + + rects.into_iter().map(|(rect, _)| [DVec2::new(rect.x0, rect.y0), DVec2::new(rect.x1, rect.y1)]).collect() + } + + /// Returns the bounding box for the cursor. + pub fn cursor_rect(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig, offset: usize, width: f64) -> [DVec2; 2] { + let Some(layout) = self.layout_text(text, font, typesetting) else { + return [DVec2::ZERO, DVec2::ZERO]; + }; + + let cursor = Cursor::from_byte_index(&layout, offset, Affinity::Downstream); + let rect = cursor.geometry(&layout, width as f32); + + [DVec2::new(rect.x0, rect.y0), DVec2::new(rect.x1, rect.y1)] + } } diff --git a/node-graph/nodes/text/src/to_path.rs b/node-graph/nodes/text/src/to_path.rs index 1ffc20df62..e988d4f9f0 100644 --- a/node-graph/nodes/text/src/to_path.rs +++ b/node-graph/nodes/text/src/to_path.rs @@ -23,6 +23,18 @@ pub fn lines_clipping(text: &str, font: &Resource, typesetting: TypesettingConfi TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, typesetting)) } +pub fn hit_test_position(text: &str, font: &Resource, typesetting: TypesettingConfig, position: DVec2) -> usize { + TextContext::with_thread_local(|ctx| ctx.hit_test_position(text, font, typesetting, position)) +} + +pub fn selection_rectangles(text: &str, font: &Resource, typesetting: TypesettingConfig, start: usize, end: usize) -> Vec<[DVec2; 2]> { + TextContext::with_thread_local(|ctx| ctx.selection_rectangles(text, font, typesetting, start, end)) +} + +pub fn cursor_rect(text: &str, font: &Resource, typesetting: TypesettingConfig, offset: usize, width: f64) -> [DVec2; 2] { + TextContext::with_thread_local(|ctx| ctx.cursor_rect(text, font, typesetting, offset, width)) +} + /// Shapes each string item of a styled `List` into vector geometry, reading its font and typesetting /// from the item's attributes (as set by the 'Text' node) and re-applying its transform and blending /// attributes onto the produced paths. With `separate_glyphs`, each glyph becomes its own item.