From 47c215f97722faaa805e345e1eaddbc8342a1b43 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Tue, 14 Apr 2026 05:45:29 +0530 Subject: [PATCH 1/6] feat: Lorem Ipsum placeholder --- editor/src/consts.rs | 4 + .../preferences_dialog_message_handler.rs | 25 ++- .../preferences/preferences_message.rs | 3 + .../preferences_message_handler.rs | 5 + .../messages/tool/tool_messages/text_tool.rs | 157 +++++++++++++++--- 5 files changed, 168 insertions(+), 26 deletions(-) diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 9913023b564..abcc280f617 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -195,3 +195,7 @@ pub const UI_SCALE_MAX: f64 = 3.; // ACTIONS pub const BLEND_COUNT_PER_LAYER: usize = 10; + +pub const LOREM_IPSUM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et erat consectetur, varius diam rutrum, condimentum ex. Mauris sit amet feugiat urna. Etiam nisi odio, iaculis ut tellus lacinia, mattis laoreet tellus. Curabitur ut nisl facilisis, consequat nibh ut, convallis velit. Morbi lectus mauris, venenatis in arcu sit amet, dignissim dictum odio. Nam nec tellus condimentum, vulputate purus molestie, hendrerit lacus. Praesent malesuada, nibh eu aliquet pulvinar, mauris eros euismod massa, non eleifend massa nisi ut urna. Quisque eu elementum neque, a bibendum risus. Quisque viverra velit sit amet odio viverra vehicula. Proin posuere eros quis ultrices porttitor. Praesent vehicula justo nibh, nec vehicula mauris facilisis sed. Integer ultricies ex vel vulputate ullamcorper. Sed ultricies odio porta dui tristique, at ullamcorper lacus sollicitudin. Proin porta tristique lectus, sit amet blandit nunc ullamcorper nec. Nullam pharetra sem at enim ultricies commodo. Nulla porttitor urna vulputate laoreet tincidunt. Sed feugiat ultricies quam at laoreet. Aliquam odio nunc, placerat eget aliquet eu, tincidunt sed odio. Donec tincidunt laoreet erat, at sodales felis tempor eu. In porttitor efficitur sagittis. Etiam ante justo, lacinia id facilisis at, molestie id nisi. Nam pellentesque, sem ornare dictum vehicula, libero urna commodo odio, eu ullamcorper nibh risus eu lectus. Sed eu bibendum arcu, eu semper mauris. Fusce porta erat vel orci dapibus, ut pharetra tortor semper. Donec interdum, elit id pellentesque pretium, urna dui eleifend lorem, in pulvinar lorem arcu ornare tellus. Integer porta lobortis ex, sed mollis orci sodales et. Donec fringilla hendrerit tortor sed pellentesque. Integer et dolor sapien. In hac habitasse platea dictumst. Nullam vitae orci arcu. Aliquam quis eros sed nulla egestas scelerisque. Donec maximus mauris diam, eu rutrum nibh blandit et. In venenatis sed sem at bibendum. Sed luctus vehicula dui, pulvinar congue elit placerat quis. Cras finibus, justo ut lacinia lacinia, libero tellus bibendum massa, sed elementum tortor lacus ut augue. Nam ultrices mauris sit amet elit venenatis mollis. Nunc sit amet sagittis dolor. Nunc quis dignissim elit, sed vulputate turpis. In sed ipsum nec purus gravida accumsan. Donec et pellentesque libero. Quisque consectetur, quam et molestie commodo, purus tortor porttitor tortor, egestas commodo metus odio sit amet odio. Praesent hendrerit consequat lobortis. Etiam et vehicula massa. Ut euismod odio tellus, aliquet condimentum tellus vehicula at. Nam sollicitudin tincidunt neque, id dapibus enim mollis id. Aenean ut vehicula est, ac porta diam. Donec in varius neque, non rutrum felis. Donec congue mi blandit arcu finibus, vel tristique neque faucibus. Mauris luctus quam sit amet ante mollis, at posuere odio sollicitudin. Proin dictum nisi neque, a cursus lorem dignissim fermentum. Cras ante nulla, venenatis in ligula vitae, ultricies imperdiet ex. Vestibulum molestie erat rhoncus arcu pulvinar lobortis. Nunc eu quam porta, lacinia diam nec, eleifend metus. Pellentesque sit amet lacus nunc. Ut venenatis eleifend efficitur. Ut ante orci, hendrerit ut egestas a, aliquam id velit. Nullam eu rhoncus sem, at fermentum magna. Vivamus laoreet orci a ante euismod maximus id accumsan nisi. Phasellus lacinia sit amet augue non commodo. Mauris bibendum sagittis condimentum."; +pub const LOREM_IPSUM_DEFAULT_WORD_COUNT: usize = 2; +pub const SAMPLE_TEXT: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "; diff --git a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs index 8c0818589a8..1d2fdb08e05 100644 --- a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs +++ b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs @@ -184,7 +184,30 @@ impl PreferencesDialogMessageHandler { selection_mode, ]; - rows.extend_from_slice(&[header, selection_label, selection_mode]); + let checkbox_id = CheckboxId::new(); + let lorem_ipsum_description = " + Fill newly created text boxes with placeholder 'Lorem ipsum' text.\n\ + \n\ + *Default: On.* + " + .trim(); + let lorem_ipsum_placeholder = vec![ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + CheckboxInput::new(preferences.lorem_ipsum_placeholder) + .tooltip_label("Lorem Ipsum Placeholder") + .tooltip_description(lorem_ipsum_description) + .on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::LoremIpsumPlaceholder { enabled: checkbox_input.checked }.into()) + .for_label(checkbox_id) + .widget_instance(), + TextLabel::new("Lorem Ipsum Placeholder") + .tooltip_label("Lorem Ipsum Placeholder") + .tooltip_description(lorem_ipsum_description) + .for_checkbox(checkbox_id) + .widget_instance(), + ]; + + rows.extend_from_slice(&[header, selection_label, selection_mode, lorem_ipsum_placeholder]); } // ========= diff --git a/editor/src/messages/preferences/preferences_message.rs b/editor/src/messages/preferences/preferences_message.rs index 9c02ee9fb17..05987ab758c 100644 --- a/editor/src/messages/preferences/preferences_message.rs +++ b/editor/src/messages/preferences/preferences_message.rs @@ -18,6 +18,9 @@ pub enum PreferencesMessage { BrushTool { enabled: bool, }, + LoremIpsumPlaceholder { + enabled: bool, + }, ModifyLayout { zoom_with_scroll: bool, }, diff --git a/editor/src/messages/preferences/preferences_message_handler.rs b/editor/src/messages/preferences/preferences_message_handler.rs index 58bfcc50355..2c183a65571 100644 --- a/editor/src/messages/preferences/preferences_message_handler.rs +++ b/editor/src/messages/preferences/preferences_message_handler.rs @@ -18,6 +18,7 @@ pub struct PreferencesMessageHandler { pub selection_mode: SelectionMode, pub zoom_with_scroll: bool, pub brush_tool: bool, + pub lorem_ipsum_placeholder: bool, pub graph_wire_style: GraphWireStyle, pub viewport_zoom_wheel_rate: f64, pub ui_scale: f64, @@ -64,6 +65,7 @@ impl Default for PreferencesMessageHandler { selection_mode: SelectionMode::Touched, zoom_with_scroll: matches!(MappingVariant::default(), MappingVariant::ZoomWithScroll), brush_tool: false, + lorem_ipsum_placeholder: true, graph_wire_style: GraphWireStyle::default(), viewport_zoom_wheel_rate: VIEWPORT_ZOOM_WHEEL_RATE, ui_scale: UI_SCALE_DEFAULT, @@ -110,6 +112,9 @@ impl MessageHandler> for Prefe responses.add(ToolMessage::RefreshToolShelf); } + PreferencesMessage::LoremIpsumPlaceholder { enabled } => { + self.lorem_ipsum_placeholder = enabled; + } PreferencesMessage::ModifyLayout { zoom_with_scroll } => { self.zoom_with_scroll = zoom_with_scroll; diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 78a947346d6..b794de5e609 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -1,7 +1,7 @@ #![allow(clippy::too_many_arguments)] use super::tool_prelude::*; -use crate::consts::{COLOR_OVERLAY_BLUE_05, COLOR_OVERLAY_RED, DRAG_THRESHOLD}; +use crate::consts::{COLOR_OVERLAY_BLACK, COLOR_OVERLAY_BLUE_05, COLOR_OVERLAY_RED, DRAG_THRESHOLD, LOREM_IPSUM, LOREM_IPSUM_DEFAULT_WORD_COUNT, SAMPLE_TEXT}; 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; @@ -17,12 +17,12 @@ use crate::messages::tool::common_functionality::snapping::{self, SnapCandidateP use crate::messages::tool::common_functionality::transformation_cage::*; use crate::messages::tool::common_functionality::utility_functions::text_bounding_box; use crate::messages::tool::utility_types::ToolRefreshOptions; -use graph_craft::application_io::resource::ResourceId; +use graph_craft::application_io::resource::{Resource, ResourceId}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::color::SRGBA8; use graphene_std::renderer::Quad; -use graphene_std::text::{Font, TextAlign, TypesettingConfig, lines_clipping}; +use graphene_std::text::{Font, TextAlign, TypesettingConfig, bounding_box, lines_clipping}; use graphene_std::vector::style::FillChoice; use graphene_std::{Color, NodeParameter}; @@ -447,6 +447,8 @@ struct TextToolData { layer: LayerNodeIdentifier, editing_text: Option, new_text: String, + is_lorem_ipsum: bool, + last_lorem_ipsum_constraint: Option, drag_start: DVec2, drag_current: DVec2, resize: Resize, @@ -667,19 +669,19 @@ impl Fsm for TextToolFsmState { TextToolFsmState::Editing } - (_, TextToolMessage::Overlays { context: mut overlay_context }) => { - if matches!(self, Self::Placing) { - // Get the updated selection box bounds - let quad = Quad::from_box(tool_data.cached_resize_bounds); - - // Draw a bounding box on the layers to be selected - for layer in document.intersect_quad_no_artboards(quad, viewport) { - overlay_context.quad(Quad::from_box(document.metadata().bounding_box_viewport(layer).unwrap_or([DVec2::ZERO; 2])), None, Some(fill_color)); - } + (TextToolFsmState::Placing, TextToolMessage::Overlays { context: mut overlay_context }) => { + // Get the updated selection box bounds + let quad = Quad::from_box(tool_data.cached_resize_bounds); - overlay_context.quad(quad, None, Some(fill_color)); + // Draw a bounding boxes on the layers to be selected + for layer in document.intersect_quad_no_artboards(quad, viewport) { + overlay_context.quad(Quad::from_box(document.metadata().bounding_box_viewport(layer).unwrap_or([DVec2::ZERO; 2])), None, Some(fill_color)); } + overlay_context.quad(quad, None, Some(fill_color)); + self + } + (_, TextToolMessage::Overlays { context: mut overlay_context }) => { // TODO: implement bounding box for multiple layers let selected = document.network_interface.selected_nodes(); let mut all_layers = selected.selected_visible_and_unlocked_layers(&document.network_interface); @@ -702,7 +704,7 @@ impl Fsm for TextToolFsmState { let transformed_quad = layer_transform * bounds; if let Some((text, font, typesetting)) = graph_modification_utils::get_text(layer.unwrap(), &document.network_interface, fonts, &document.resources) { let font_resource = fonts.get_resource_or_queue_load(&font, responses); - if lines_clipping(text.as_str(), &font_resource, typesetting) { + if lines_clipping(text.as_str(), &font_resource, typesetting) && !tool_data.is_lorem_ipsum { overlay_context.line(transformed_quad.0[2], transformed_quad.0[3], Some(COLOR_OVERLAY_RED), Some(3.)); } } @@ -804,6 +806,43 @@ impl Fsm for TextToolFsmState { responses.add(OverlaysMessage::Draw); + let [start, end] = tool_data.cached_resize_bounds; + let has_dragged = (start - end).length_squared() > DRAG_THRESHOLD * DRAG_THRESHOLD; + let constraint_size = has_dragged.then_some((start - end).abs() / viewport_zoom(document)); + let floor_constraint = constraint_size.map(|c| c.floor()); + if tool_data.last_lorem_ipsum_constraint != floor_constraint { + tool_data.last_lorem_ipsum_constraint = floor_constraint; + + if transition_data.preferences.lorem_ipsum_placeholder { + let typesetting = TypesettingConfig { + font_size: tool_options.font_size, + letter_spacing: tool_options.letter_spacing, + letter_tilt: tool_options.letter_tilt, + max_width: constraint_size.map(|size| size.x), + max_height: constraint_size.map(|size| size.y), + align: tool_options.align, + ..TypesettingConfig::default() + }; + let font_resource = fonts.get_resource_or_queue_load(&tool_options.font, responses); + let text = get_lorem_ipsum_text(constraint_size, &font_resource, typesetting); + let position = start.min(end); + let (align, align_last) = tool_options.align.css(); + + responses.add(FrontendMessage::DisplayEditableTextbox { + text, + line_height_ratio: typesetting.line_height_ratio, + font_size: tool_options.font_size, + color: tool_options.fill.active_color().map_or(COLOR_OVERLAY_BLACK.to_string(), |color| SRGBA8::from(color).to_css_hex()), + font_data: font_resource.as_ref().to_vec().into(), + transform: window_aligned_transform(document, position, DVec2::ONE).to_cols_array(), + max_width: constraint_size.map(|size| size.x), + max_height: constraint_size.map(|size| size.y), + align: align.to_string(), + align_last: align_last.to_string(), + }); + } + } + // Auto-panning let messages = [ TextToolMessage::PointerOutsideViewport { center, lock_ratio }.into(), @@ -950,6 +989,7 @@ impl Fsm for TextToolFsmState { if let Some(bounds) = &mut tool_data.bounding_box_manager { bounds.original_transforms.clear(); } + tool_data.last_lorem_ipsum_constraint = None; TextToolFsmState::Ready } @@ -960,23 +1000,33 @@ 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.last_lorem_ipsum_constraint = None; 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 typesetting = TypesettingConfig { + font_size: tool_options.font_size, + letter_spacing: tool_options.letter_spacing, + letter_tilt: tool_options.letter_tilt, + max_width: constraint_size.map(|size| size.x), + max_height: constraint_size.map(|size| size.y), + align: tool_options.align, + ..TypesettingConfig::default() + }; + let text = if transition_data.preferences.lorem_ipsum_placeholder { + tool_data.is_lorem_ipsum = true; + let font_resource = fonts.get_resource_or_queue_load(&tool_options.font, responses); + get_lorem_ipsum_text(constraint_size, &font_resource, typesetting) + } else { + tool_data.is_lorem_ipsum = false; + String::new() + }; let editing_text = EditingText { - text: String::new(), - transform: window_aligned_transform(document, start, DVec2::ONE), - typesetting: TypesettingConfig { - font_size: tool_options.font_size, - letter_spacing: tool_options.letter_spacing, - letter_tilt: tool_options.letter_tilt, - max_width: constraint_size.map(|size| size.x), - max_height: constraint_size.map(|size| size.y), - align: tool_options.align, - ..TypesettingConfig::default() - }, + text, + transform: window_aligned_transform(document, start.min(end), DVec2::ONE), + typesetting, font: Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()), color: tool_options.fill.active_color(), }; @@ -999,6 +1049,7 @@ impl Fsm for TextToolFsmState { return TextToolFsmState::Editing; } tool_data.layer_dragging.take(); + tool_data.last_lorem_ipsum_constraint = None; TextToolFsmState::Ready } @@ -1015,6 +1066,7 @@ impl Fsm for TextToolFsmState { tool_data.new_text = new_text; if !is_left_or_right_click { + truncate_lorem_ipsum(tool_data, fonts, responses); tool_data.set_editing(false, fonts, responses); responses.add(NodeGraphMessage::SetInput { @@ -1028,6 +1080,7 @@ impl Fsm for TextToolFsmState { TextToolFsmState::Ready } else { + truncate_lorem_ipsum(tool_data, fonts, responses); if tool_data.new_text.is_empty() { return tool_data.delete_empty_layer(fonts, responses); } @@ -1039,6 +1092,7 @@ impl Fsm for TextToolFsmState { } (TextToolFsmState::Editing, TextToolMessage::UpdateBounds { new_text }) => { tool_data.new_text = new_text; + tool_data.is_lorem_ipsum = false; responses.add(OverlaysMessage::Draw); TextToolFsmState::Editing } @@ -1069,6 +1123,7 @@ impl Fsm for TextToolFsmState { input.mouse.finish_transaction(tool_data.resize.viewport_drag_start(document), responses); } tool_data.resize.cleanup(responses); + tool_data.last_lorem_ipsum_constraint = None; TextToolFsmState::Ready } @@ -1113,3 +1168,55 @@ impl Fsm for TextToolFsmState { responses.add(FrontendMessage::UpdateMouseCursor { cursor }); } } + +/// Get the lorem ipsum text for the specified constraint size +fn get_lorem_ipsum_text(constraint_size: Option, font_resource: &Resource, typesetting: TypesettingConfig) -> String { + let word_count = if let Some(size) = constraint_size { + let sample_width = bounding_box(SAMPLE_TEXT, font_resource, typesetting, false).x; + let average_advance = if sample_width > 0. { + sample_width / SAMPLE_TEXT.len() as f64 + } else { + typesetting.font_size * 0.45 + }; + + let line_height = typesetting.font_size * typesetting.line_height_ratio; + if line_height <= 0. || average_advance <= 0. { + return LOREM_IPSUM.split_whitespace().take(LOREM_IPSUM_DEFAULT_WORD_COUNT).collect::>().join(" "); + } + let chars_per_line = (size.x / average_advance).floor().max(1.) as usize; + let lines = (size.y / line_height).floor().max(1.) as usize; + + let chars = (chars_per_line * lines) as f64 * 1.5; + (chars / 5.5).ceil() as usize + } else { + LOREM_IPSUM_DEFAULT_WORD_COUNT + }; + + LOREM_IPSUM.split_whitespace().cycle().take(word_count).collect::>().join(" ") +} + +/// Truncate the lorem ipsum text to fit within the specified height +fn truncate_lorem_ipsum(tool_data: &mut TextToolData, fonts: &FontsMessageHandler, responses: &mut VecDeque) { + if tool_data.is_lorem_ipsum + && let Some(editing_text) = tool_data.editing_text.as_ref() + && let Some(max_height) = editing_text.typesetting.max_height + { + let font_resource = fonts.get_resource_or_queue_load(&editing_text.font, responses); + let words: Vec<&str> = tool_data.new_text.split_whitespace().collect(); + let mut low = 1; + let mut high = words.len(); + let mut best_fit = 0; + while low <= high { + let mid = (low + high) / 2; + let test_text = words[..mid].join(" "); + let box_y = bounding_box(&test_text, &font_resource, editing_text.typesetting, true).y; + if box_y <= max_height { + best_fit = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + tool_data.new_text = words[..best_fit].join(" "); + } +} From 06d997fc42deddcbf81de74b3e2e4fd67f9bc3c0 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Tue, 14 Apr 2026 06:02:56 +0530 Subject: [PATCH 2/6] chore: code review --- editor/src/messages/tool/tool_messages/text_tool.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index b794de5e609..6882de91451 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -529,6 +529,7 @@ impl TextToolData { } self.layer = layer; + self.is_lorem_ipsum = false; // Editing an existing layer — discard any lorem ipsum state if self.load_layer_text_node(document, fonts).is_some() { responses.add(DocumentMessage::AddTransaction); From d2d754091fc205f23829440fb409576545d4ee41 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Sun, 20 Sep 2026 20:56:41 +0530 Subject: [PATCH 3/6] chore: migrate to the new lorem ipsum library --- Cargo.lock | 2 + editor/Cargo.toml | 2 + editor/src/consts.rs | 4 - .../src/messages/frontend/frontend_message.rs | 1 + .../messages/tool/tool_messages/text_tool.rs | 235 +++++++++++++----- .../src/components/panels/Document.svelte | 35 +-- 6 files changed, 192 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 07b29735a75..a4168f611e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2357,6 +2357,7 @@ dependencies = [ "graphite-proc-macros", "image", "interpreted-executor", + "ipsum", "js-sys", "kurbo", "log", @@ -2365,6 +2366,7 @@ dependencies = [ "num_enum", "once_cell", "preprocessor", + "rand", "reqwest", "serde", "serde_bytes", diff --git a/editor/Cargo.toml b/editor/Cargo.toml index 5c7256571b1..24c2347c27c 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -24,6 +24,7 @@ interpreted-executor = { workspace = true } graphene-std = { workspace = true } # NOTE: `core-types` should not be added here because `graphene-std` re-exports its contents preprocessor = { workspace = true } math-parser = { workspace = true } +ipsum = { workspace = true } # Workspace dependencies js-sys = { workspace = true } @@ -52,6 +53,7 @@ color = { workspace = true } zip = { workspace = true } reqwest = { workspace = true } url = { workspace = true } +rand = { workspace = true } # Optional local dependencies wgpu-executor = { workspace = true, optional = true } diff --git a/editor/src/consts.rs b/editor/src/consts.rs index abcc280f617..9913023b564 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -195,7 +195,3 @@ pub const UI_SCALE_MAX: f64 = 3.; // ACTIONS pub const BLEND_COUNT_PER_LAYER: usize = 10; - -pub const LOREM_IPSUM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et erat consectetur, varius diam rutrum, condimentum ex. Mauris sit amet feugiat urna. Etiam nisi odio, iaculis ut tellus lacinia, mattis laoreet tellus. Curabitur ut nisl facilisis, consequat nibh ut, convallis velit. Morbi lectus mauris, venenatis in arcu sit amet, dignissim dictum odio. Nam nec tellus condimentum, vulputate purus molestie, hendrerit lacus. Praesent malesuada, nibh eu aliquet pulvinar, mauris eros euismod massa, non eleifend massa nisi ut urna. Quisque eu elementum neque, a bibendum risus. Quisque viverra velit sit amet odio viverra vehicula. Proin posuere eros quis ultrices porttitor. Praesent vehicula justo nibh, nec vehicula mauris facilisis sed. Integer ultricies ex vel vulputate ullamcorper. Sed ultricies odio porta dui tristique, at ullamcorper lacus sollicitudin. Proin porta tristique lectus, sit amet blandit nunc ullamcorper nec. Nullam pharetra sem at enim ultricies commodo. Nulla porttitor urna vulputate laoreet tincidunt. Sed feugiat ultricies quam at laoreet. Aliquam odio nunc, placerat eget aliquet eu, tincidunt sed odio. Donec tincidunt laoreet erat, at sodales felis tempor eu. In porttitor efficitur sagittis. Etiam ante justo, lacinia id facilisis at, molestie id nisi. Nam pellentesque, sem ornare dictum vehicula, libero urna commodo odio, eu ullamcorper nibh risus eu lectus. Sed eu bibendum arcu, eu semper mauris. Fusce porta erat vel orci dapibus, ut pharetra tortor semper. Donec interdum, elit id pellentesque pretium, urna dui eleifend lorem, in pulvinar lorem arcu ornare tellus. Integer porta lobortis ex, sed mollis orci sodales et. Donec fringilla hendrerit tortor sed pellentesque. Integer et dolor sapien. In hac habitasse platea dictumst. Nullam vitae orci arcu. Aliquam quis eros sed nulla egestas scelerisque. Donec maximus mauris diam, eu rutrum nibh blandit et. In venenatis sed sem at bibendum. Sed luctus vehicula dui, pulvinar congue elit placerat quis. Cras finibus, justo ut lacinia lacinia, libero tellus bibendum massa, sed elementum tortor lacus ut augue. Nam ultrices mauris sit amet elit venenatis mollis. Nunc sit amet sagittis dolor. Nunc quis dignissim elit, sed vulputate turpis. In sed ipsum nec purus gravida accumsan. Donec et pellentesque libero. Quisque consectetur, quam et molestie commodo, purus tortor porttitor tortor, egestas commodo metus odio sit amet odio. Praesent hendrerit consequat lobortis. Etiam et vehicula massa. Ut euismod odio tellus, aliquet condimentum tellus vehicula at. Nam sollicitudin tincidunt neque, id dapibus enim mollis id. Aenean ut vehicula est, ac porta diam. Donec in varius neque, non rutrum felis. Donec congue mi blandit arcu finibus, vel tristique neque faucibus. Mauris luctus quam sit amet ante mollis, at posuere odio sollicitudin. Proin dictum nisi neque, a cursus lorem dignissim fermentum. Cras ante nulla, venenatis in ligula vitae, ultricies imperdiet ex. Vestibulum molestie erat rhoncus arcu pulvinar lobortis. Nunc eu quam porta, lacinia diam nec, eleifend metus. Pellentesque sit amet lacus nunc. Ut venenatis eleifend efficitur. Ut ante orci, hendrerit ut egestas a, aliquam id velit. Nullam eu rhoncus sem, at fermentum magna. Vivamus laoreet orci a ante euismod maximus id accumsan nisi. Phasellus lacinia sit amet augue non commodo. Mauris bibendum sagittis condimentum."; -pub const LOREM_IPSUM_DEFAULT_WORD_COUNT: usize = 2; -pub const SAMPLE_TEXT: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "; diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index f309f51d617..b6b1de5a3d4 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -38,6 +38,7 @@ pub enum FrontendMessage { }, DisplayEditableTextbox { text: String, + editable: bool, #[serde(rename = "lineHeightRatio")] line_height_ratio: f64, #[serde(rename = "fontSize")] diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 6882de91451..d9f20428ae5 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -1,7 +1,7 @@ #![allow(clippy::too_many_arguments)] use super::tool_prelude::*; -use crate::consts::{COLOR_OVERLAY_BLACK, COLOR_OVERLAY_BLUE_05, COLOR_OVERLAY_RED, DRAG_THRESHOLD, LOREM_IPSUM, LOREM_IPSUM_DEFAULT_WORD_COUNT, SAMPLE_TEXT}; +use crate::consts::{COLOR_OVERLAY_BLACK, COLOR_OVERLAY_BLUE_05, COLOR_OVERLAY_RED, DRAG_THRESHOLD}; 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; @@ -17,7 +17,7 @@ use crate::messages::tool::common_functionality::snapping::{self, SnapCandidateP use crate::messages::tool::common_functionality::transformation_cage::*; use crate::messages::tool::common_functionality::utility_functions::text_bounding_box; use crate::messages::tool::utility_types::ToolRefreshOptions; -use graph_craft::application_io::resource::{Resource, ResourceId}; +use graph_craft::application_io::resource::{Resource, ResourceHash, ResourceId}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::color::SRGBA8; @@ -25,6 +25,14 @@ use graphene_std::renderer::Quad; use graphene_std::text::{Font, TextAlign, TypesettingConfig, bounding_box, lines_clipping}; use graphene_std::vector::style::FillChoice; use graphene_std::{Color, NodeParameter}; +use rand::{Rng, SeedableRng}; + +const DEFAULT_LOREM_IPSUM_WORD_COUNT: usize = 2; +const MAX_LOREM_IPSUM_WORD_COUNT: usize = 10_000; +const LOREM_IPSUM_SEED: u64 = 0; +const AVERAGE_GLYPH_SAMPLE: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ "; +const AVERAGE_CHARACTERS_PER_WORD: f64 = 6.5; +const WORD_COUNT_FILL_MARGIN: f64 = 1.5; #[derive(Default, ExtractField)] pub struct TextTool { @@ -449,6 +457,10 @@ struct TextToolData { new_text: String, is_lorem_ipsum: bool, last_lorem_ipsum_constraint: Option, + lorem_ipsum_preview: String, + lorem_ipsum_preview_word_count: usize, + lorem_ipsum_preview_font_hash: Option, + lorem_ipsum_preview_average_advance: Option, drag_start: DVec2, drag_current: DVec2, resize: Resize, @@ -463,6 +475,9 @@ struct TextToolData { impl TextToolData { fn delete_empty_layer(&mut self, fonts: &FontsMessageHandler, responses: &mut VecDeque) -> TextToolFsmState { + self.is_lorem_ipsum = false; + self.reset_lorem_ipsum_preview(); + // Remove the editable textbox UI first self.set_editing(false, fonts, responses); @@ -483,6 +498,7 @@ impl TextToolData { 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(), + editable: true, 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()), @@ -529,7 +545,8 @@ impl TextToolData { } self.layer = layer; - self.is_lorem_ipsum = false; // Editing an existing layer — discard any lorem ipsum state + self.is_lorem_ipsum = false; + self.reset_lorem_ipsum_preview(); if self.load_layer_text_node(document, fonts).is_some() { responses.add(DocumentMessage::AddTransaction); @@ -549,7 +566,7 @@ impl TextToolData { } fn new_text(&mut self, document: &DocumentMessageHandler, editing_text: EditingText, fonts: &FontsMessageHandler, responses: &mut VecDeque) { - self.new_text = String::new(); + self.new_text.clone_from(&editing_text.text); responses.add(DocumentMessage::AddTransaction); self.layer = LayerNodeIdentifier::new_unchecked(NodeId::new()); @@ -611,6 +628,29 @@ impl TextToolData { snapping::get_bbox_points(quad, &mut self.snap_candidates, snapping::BBoxSnapValues::BOUNDING_BOX, document); } } + + fn lorem_ipsum_preview(&mut self, word_count: usize) -> String { + let generated_word_count = word_count + .max(DEFAULT_LOREM_IPSUM_WORD_COUNT) + .checked_next_power_of_two() + .unwrap_or(MAX_LOREM_IPSUM_WORD_COUNT) + .min(MAX_LOREM_IPSUM_WORD_COUNT); + + if generated_word_count > self.lorem_ipsum_preview_word_count { + self.lorem_ipsum_preview = generate_lorem_ipsum(generated_word_count); + self.lorem_ipsum_preview_word_count = generated_word_count; + } + + word_prefix(&self.lorem_ipsum_preview, word_count).to_owned() + } + + fn reset_lorem_ipsum_preview(&mut self) { + self.last_lorem_ipsum_constraint = None; + self.lorem_ipsum_preview = String::new(); + self.lorem_ipsum_preview_word_count = 0; + self.lorem_ipsum_preview_font_hash = None; + self.lorem_ipsum_preview_average_advance = None; + } } fn can_edit_selected(document: &DocumentMessageHandler) -> Option { @@ -674,7 +714,7 @@ impl Fsm for TextToolFsmState { // Get the updated selection box bounds let quad = Quad::from_box(tool_data.cached_resize_bounds); - // Draw a bounding boxes on the layers to be selected + // Draw bounding boxes on the layers to be selected for layer in document.intersect_quad_no_artboards(quad, viewport) { overlay_context.quad(Quad::from_box(document.metadata().bounding_box_viewport(layer).unwrap_or([DVec2::ZERO; 2])), None, Some(fill_color)); } @@ -705,7 +745,7 @@ impl Fsm for TextToolFsmState { let transformed_quad = layer_transform * bounds; if let Some((text, font, typesetting)) = graph_modification_utils::get_text(layer.unwrap(), &document.network_interface, fonts, &document.resources) { let font_resource = fonts.get_resource_or_queue_load(&font, responses); - if lines_clipping(text.as_str(), &font_resource, typesetting) && !tool_data.is_lorem_ipsum { + if lines_clipping(text.as_str(), &font_resource, typesetting) { overlay_context.line(transformed_quad.0[2], transformed_quad.0[3], Some(COLOR_OVERLAY_RED), Some(3.)); } } @@ -815,26 +855,25 @@ impl Fsm for TextToolFsmState { tool_data.last_lorem_ipsum_constraint = floor_constraint; if transition_data.preferences.lorem_ipsum_placeholder { - let typesetting = TypesettingConfig { - font_size: tool_options.font_size, - letter_spacing: tool_options.letter_spacing, - letter_tilt: tool_options.letter_tilt, - max_width: constraint_size.map(|size| size.x), - max_height: constraint_size.map(|size| size.y), - align: tool_options.align, - ..TypesettingConfig::default() - }; + let typesetting = new_text_typesetting(tool_options, constraint_size); let font_resource = fonts.get_resource_or_queue_load(&tool_options.font, responses); - let text = get_lorem_ipsum_text(constraint_size, &font_resource, typesetting); + let font_hash = font_resource.hash(); + if tool_data.lorem_ipsum_preview_font_hash != Some(font_hash) { + tool_data.lorem_ipsum_preview_font_hash = Some(font_hash); + tool_data.lorem_ipsum_preview_average_advance = average_glyph_advance(&font_resource, typesetting); + } + let word_count = lorem_ipsum_word_count(constraint_size, tool_data.lorem_ipsum_preview_average_advance, typesetting); + let text = tool_data.lorem_ipsum_preview(word_count); let position = start.min(end); let (align, align_last) = tool_options.align.css(); responses.add(FrontendMessage::DisplayEditableTextbox { text, + editable: false, line_height_ratio: typesetting.line_height_ratio, font_size: tool_options.font_size, color: tool_options.fill.active_color().map_or(COLOR_OVERLAY_BLACK.to_string(), |color| SRGBA8::from(color).to_css_hex()), - font_data: font_resource.as_ref().to_vec().into(), + font_data: Vec::new().into(), transform: window_aligned_transform(document, position, DVec2::ONE).to_cols_array(), max_width: constraint_size.map(|size| size.x), max_height: constraint_size.map(|size| size.y), @@ -990,7 +1029,7 @@ impl Fsm for TextToolFsmState { if let Some(bounds) = &mut tool_data.bounding_box_manager { bounds.original_transforms.clear(); } - tool_data.last_lorem_ipsum_constraint = None; + tool_data.reset_lorem_ipsum_preview(); TextToolFsmState::Ready } @@ -1001,29 +1040,22 @@ 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.last_lorem_ipsum_constraint = None; 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 typesetting = TypesettingConfig { - font_size: tool_options.font_size, - letter_spacing: tool_options.letter_spacing, - letter_tilt: tool_options.letter_tilt, - max_width: constraint_size.map(|size| size.x), - max_height: constraint_size.map(|size| size.y), - align: tool_options.align, - ..TypesettingConfig::default() - }; + let typesetting = new_text_typesetting(tool_options, constraint_size); let text = if transition_data.preferences.lorem_ipsum_placeholder { tool_data.is_lorem_ipsum = true; let font_resource = fonts.get_resource_or_queue_load(&tool_options.font, responses); - get_lorem_ipsum_text(constraint_size, &font_resource, typesetting) + let word_count = lorem_ipsum_word_count(constraint_size, average_glyph_advance(&font_resource, typesetting), typesetting); + generate_lorem_ipsum(word_count) } else { tool_data.is_lorem_ipsum = false; String::new() }; + tool_data.reset_lorem_ipsum_preview(); let editing_text = EditingText { text, transform: window_aligned_transform(document, start.min(end), DVec2::ONE), @@ -1050,7 +1082,7 @@ impl Fsm for TextToolFsmState { return TextToolFsmState::Editing; } tool_data.layer_dragging.take(); - tool_data.last_lorem_ipsum_constraint = None; + tool_data.reset_lorem_ipsum_preview(); TextToolFsmState::Ready } @@ -1067,7 +1099,12 @@ impl Fsm for TextToolFsmState { tool_data.new_text = new_text; if !is_left_or_right_click { - truncate_lorem_ipsum(tool_data, fonts, responses); + let was_lorem_ipsum = tool_data.is_lorem_ipsum; + fit_lorem_ipsum_to_height(tool_data, fonts, responses); + if was_lorem_ipsum && tool_data.new_text.is_empty() { + return tool_data.delete_empty_layer(fonts, responses); + } + tool_data.is_lorem_ipsum = false; tool_data.set_editing(false, fonts, responses); responses.add(NodeGraphMessage::SetInput { @@ -1081,7 +1118,6 @@ impl Fsm for TextToolFsmState { TextToolFsmState::Ready } else { - truncate_lorem_ipsum(tool_data, fonts, responses); if tool_data.new_text.is_empty() { return tool_data.delete_empty_layer(fonts, responses); } @@ -1124,7 +1160,10 @@ impl Fsm for TextToolFsmState { input.mouse.finish_transaction(tool_data.resize.viewport_drag_start(document), responses); } tool_data.resize.cleanup(responses); - tool_data.last_lorem_ipsum_constraint = None; + if state == TextToolFsmState::Placing { + responses.add(FrontendMessage::DisplayRemoveEditableTextbox); + } + tool_data.reset_lorem_ipsum_preview(); TextToolFsmState::Ready } @@ -1170,54 +1209,114 @@ impl Fsm for TextToolFsmState { } } -/// Get the lorem ipsum text for the specified constraint size -fn get_lorem_ipsum_text(constraint_size: Option, font_resource: &Resource, typesetting: TypesettingConfig) -> String { - let word_count = if let Some(size) = constraint_size { - let sample_width = bounding_box(SAMPLE_TEXT, font_resource, typesetting, false).x; - let average_advance = if sample_width > 0. { - sample_width / SAMPLE_TEXT.len() as f64 - } else { - typesetting.font_size * 0.45 - }; - - let line_height = typesetting.font_size * typesetting.line_height_ratio; - if line_height <= 0. || average_advance <= 0. { - return LOREM_IPSUM.split_whitespace().take(LOREM_IPSUM_DEFAULT_WORD_COUNT).collect::>().join(" "); - } - let chars_per_line = (size.x / average_advance).floor().max(1.) as usize; - let lines = (size.y / line_height).floor().max(1.) as usize; +fn new_text_typesetting(tool_options: &TextOptions, constraint_size: Option) -> TypesettingConfig { + TypesettingConfig { + font_size: tool_options.font_size, + letter_spacing: tool_options.letter_spacing, + letter_tilt: tool_options.letter_tilt, + max_width: constraint_size.map(|size| size.x), + max_height: constraint_size.map(|size| size.y), + align: tool_options.align, + ..TypesettingConfig::default() + } +} - let chars = (chars_per_line * lines) as f64 * 1.5; - (chars / 5.5).ceil() as usize +fn average_glyph_advance(font_resource: &Resource, typesetting: TypesettingConfig) -> Option { + let measurement_typesetting = TypesettingConfig { + max_width: None, + max_height: None, + ..typesetting + }; + let sample_width = bounding_box(AVERAGE_GLYPH_SAMPLE, font_resource, measurement_typesetting, false).x; + let average_advance = if sample_width.is_finite() && sample_width > 0. { + sample_width / AVERAGE_GLYPH_SAMPLE.len() as f64 } else { - LOREM_IPSUM_DEFAULT_WORD_COUNT + typesetting.font_size * 0.45 }; + (average_advance.is_finite() && average_advance > 0.).then_some(average_advance) +} + +fn lorem_ipsum_word_count(constraint_size: Option, average_advance: Option, typesetting: TypesettingConfig) -> usize { + let Some(size) = constraint_size else { return DEFAULT_LOREM_IPSUM_WORD_COUNT }; + let Some(average_advance) = average_advance else { return DEFAULT_LOREM_IPSUM_WORD_COUNT }; + if !size.is_finite() || size.x <= 0. || size.y <= 0. { + return DEFAULT_LOREM_IPSUM_WORD_COUNT; + } + + let line_height = typesetting.font_size * typesetting.line_height_ratio; + if !line_height.is_finite() || line_height <= 0. { + return DEFAULT_LOREM_IPSUM_WORD_COUNT; + } + + let chars_per_line = (size.x / average_advance).floor().max(1.); + let lines = (size.y / line_height).floor().max(1.); + // Overgenerate enough to cover uneven line endings, then trim precisely to the height constraint when committing. + let estimated_words = (chars_per_line * lines * WORD_COUNT_FILL_MARGIN / AVERAGE_CHARACTERS_PER_WORD).ceil(); + estimated_words.clamp(DEFAULT_LOREM_IPSUM_WORD_COUNT as f64, MAX_LOREM_IPSUM_WORD_COUNT as f64) as usize +} - LOREM_IPSUM.split_whitespace().cycle().take(word_count).collect::>().join(" ") +fn generate_lorem_ipsum(word_count: usize) -> String { + let word_count = word_count.clamp(DEFAULT_LOREM_IPSUM_WORD_COUNT, MAX_LOREM_IPSUM_WORD_COUNT); + let mut rng = rand::rngs::StdRng::seed_from_u64(LOREM_IPSUM_SEED); + ipsum::generate(word_count, ipsum::Unit::Words, word_count, ipsum::Unit::Words, |length| rng.random_range(0..length)) } -/// Truncate the lorem ipsum text to fit within the specified height -fn truncate_lorem_ipsum(tool_data: &mut TextToolData, fonts: &FontsMessageHandler, responses: &mut VecDeque) { +fn word_prefix(text: &str, word_count: usize) -> &str { + if word_count == 0 { + return ""; + } + + let end = text + .char_indices() + .filter_map(|(index, character)| character.is_whitespace().then_some(index)) + .nth(word_count - 1) + .unwrap_or(text.len()); + &text[..end] +} + +/// Fit generated placeholder text to its height constraint +fn fit_lorem_ipsum_to_height(tool_data: &mut TextToolData, fonts: &FontsMessageHandler, responses: &mut VecDeque) { if tool_data.is_lorem_ipsum && let Some(editing_text) = tool_data.editing_text.as_ref() && let Some(max_height) = editing_text.typesetting.max_height { let font_resource = fonts.get_resource_or_queue_load(&editing_text.font, responses); - let words: Vec<&str> = tool_data.new_text.split_whitespace().collect(); - let mut low = 1; - let mut high = words.len(); - let mut best_fit = 0; - while low <= high { - let mid = (low + high) / 2; - let test_text = words[..mid].join(" "); - let box_y = bounding_box(&test_text, &font_resource, editing_text.typesetting, true).y; - if box_y <= max_height { - best_fit = mid; - low = mid + 1; + let mut word_ends = Vec::with_capacity(tool_data.new_text.split_whitespace().count()); + let mut inside_word = false; + for (index, character) in tool_data.new_text.char_indices() { + if character.is_whitespace() { + if inside_word { + word_ends.push(index); + inside_word = false; + } } else { - high = mid - 1; + inside_word = true; + } + } + if inside_word { + word_ends.push(tool_data.new_text.len()); + } + + let mut low = 0; + let mut high = word_ends.len(); + while low < high { + let middle = (low + high).div_ceil(2); + let text = &tool_data.new_text[..word_ends[middle - 1]]; + if bounding_box(text, &font_resource, editing_text.typesetting, true).y <= max_height { + low = middle; + } else { + high = middle - 1; + } + } + + while low > 0 { + let text = generate_lorem_ipsum(low); + if bounding_box(&text, &font_resource, editing_text.typesetting, true).y <= max_height { + tool_data.new_text = text; + return; } + low -= 1; } - tool_data.new_text = words[..best_fit].join(" "); + tool_data.new_text.clear(); } } diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index e80ed12fe0a..1692573b498 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -34,6 +34,7 @@ // Interactive text editing let textInput: undefined | HTMLDivElement = undefined; let showTextInput: boolean; + let textInputEditable = false; let textInputMatrix: [number, number, number, number, number, number]; // Scrollbars @@ -361,6 +362,7 @@ export async function displayEditableTextbox(data: MessageBody<"DisplayEditableTextbox">) { showTextInput = true; + textInputEditable = data.editable; await tick(); @@ -375,7 +377,8 @@ const lineHeight = data.lineHeightRatio * data.fontSize; let height = data.maxHeight === undefined ? "auto" : `${Math.floor(data.maxHeight / lineHeight) * lineHeight}px`; - textInput.contentEditable = "true"; + textInput.contentEditable = data.editable ? "true" : "false"; + textInput.style.pointerEvents = data.editable ? "auto" : "none"; textInput.style.transformOrigin = "0 0"; textInput.style.width = data.maxWidth ? `${data.maxWidth}px` : "max-content"; textInput.style.height = height; @@ -400,27 +403,29 @@ textInput.style.fontFamily = "text-font"; } - // Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060 + if (data.editable) { + // 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 range = window.document.createRange(); - range.selectNodeContents(textInput); - - const selection = window.getSelection(); - if (selection) { - selection.removeAllRanges(); - selection.addRange(range); - } + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } - textInput.focus(); - textInput.click(); + 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 })); + // 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; + textInputEditable = false; } function updateViewportInfo() { @@ -700,7 +705,7 @@ {@html artworkSvg} {/if} -
+
{#if showTextInput}
{/if} From a2ab5e52f75e98858a11b27667505d5cd2fb4f62 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 21 Sep 2026 04:22:50 +0530 Subject: [PATCH 4/6] chore: review and cleanup --- .../messages/tool/tool_messages/text_tool.rs | 77 +++++++++++-------- .../src/components/panels/Document.svelte | 14 ++-- 2 files changed, 51 insertions(+), 40 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index d9f20428ae5..d22485d5fcc 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -375,6 +375,7 @@ impl<'a> MessageHandler> for Text refresh_slot_working_color(&mut self.options.fill, context.global_tool_data.primary_color, context.document); } } + self.tool_data.last_lorem_ipsum_constraint = None; self.send_layout(responses, LayoutTarget::ToolOptions, &context.fonts.font_catalog, context.document); } @@ -460,6 +461,7 @@ struct TextToolData { lorem_ipsum_preview: String, lorem_ipsum_preview_word_count: usize, lorem_ipsum_preview_font_hash: Option, + lorem_ipsum_preview_metric_typesetting: Option<(f64, f64)>, lorem_ipsum_preview_average_advance: Option, drag_start: DVec2, drag_current: DVec2, @@ -649,6 +651,7 @@ impl TextToolData { self.lorem_ipsum_preview = String::new(); self.lorem_ipsum_preview_word_count = 0; self.lorem_ipsum_preview_font_hash = None; + self.lorem_ipsum_preview_metric_typesetting = None; self.lorem_ipsum_preview_average_advance = None; } } @@ -710,19 +713,19 @@ impl Fsm for TextToolFsmState { TextToolFsmState::Editing } - (TextToolFsmState::Placing, TextToolMessage::Overlays { context: mut overlay_context }) => { - // Get the updated selection box bounds - let quad = Quad::from_box(tool_data.cached_resize_bounds); + (_, TextToolMessage::Overlays { context: mut overlay_context }) => { + if matches!(self, Self::Placing) { + // Get the updated selection box bounds + let quad = Quad::from_box(tool_data.cached_resize_bounds); + + // Draw a bounding box on the layers to be selected + for layer in document.intersect_quad_no_artboards(quad, viewport) { + overlay_context.quad(Quad::from_box(document.metadata().bounding_box_viewport(layer).unwrap_or([DVec2::ZERO; 2])), None, Some(fill_color)); + } - // Draw bounding boxes on the layers to be selected - for layer in document.intersect_quad_no_artboards(quad, viewport) { - overlay_context.quad(Quad::from_box(document.metadata().bounding_box_viewport(layer).unwrap_or([DVec2::ZERO; 2])), None, Some(fill_color)); + overlay_context.quad(quad, None, Some(fill_color)); } - overlay_context.quad(quad, None, Some(fill_color)); - self - } - (_, TextToolMessage::Overlays { context: mut overlay_context }) => { // TODO: implement bounding box for multiple layers let selected = document.network_interface.selected_nodes(); let mut all_layers = selected.selected_visible_and_unlocked_layers(&document.network_interface); @@ -847,24 +850,32 @@ impl Fsm for TextToolFsmState { responses.add(OverlaysMessage::Draw); - let [start, end] = tool_data.cached_resize_bounds; - let has_dragged = (start - end).length_squared() > DRAG_THRESHOLD * DRAG_THRESHOLD; - let constraint_size = has_dragged.then_some((start - end).abs() / viewport_zoom(document)); - let floor_constraint = constraint_size.map(|c| c.floor()); - if tool_data.last_lorem_ipsum_constraint != floor_constraint { - tool_data.last_lorem_ipsum_constraint = floor_constraint; + if transition_data.preferences.lorem_ipsum_placeholder { + let [start, end] = tool_data.cached_resize_bounds; + let has_dragged = (start - end).length_squared() > DRAG_THRESHOLD * DRAG_THRESHOLD; + let constraint_size = has_dragged.then_some((start - end).abs() / viewport_zoom(document)); + let floor_constraint = constraint_size.map(|c| c.floor()); + if tool_data.last_lorem_ipsum_constraint != floor_constraint { + tool_data.last_lorem_ipsum_constraint = floor_constraint; - if transition_data.preferences.lorem_ipsum_placeholder { let typesetting = new_text_typesetting(tool_options, constraint_size); - let font_resource = fonts.get_resource_or_queue_load(&tool_options.font, responses); - let font_hash = font_resource.hash(); - if tool_data.lorem_ipsum_preview_font_hash != Some(font_hash) { - tool_data.lorem_ipsum_preview_font_hash = Some(font_hash); - tool_data.lorem_ipsum_preview_average_advance = average_glyph_advance(&font_resource, typesetting); + let mut font_data = Vec::new(); + if constraint_size.is_some() { + let font_resource = fonts.get_resource_or_queue_load(&tool_options.font, responses); + let font_hash = font_resource.hash(); + let font_changed = tool_data.lorem_ipsum_preview_font_hash != Some(font_hash); + let metric_typesetting = (typesetting.font_size, typesetting.letter_spacing); + if font_changed { + tool_data.lorem_ipsum_preview_font_hash = Some(font_hash); + font_data.extend_from_slice(font_resource.as_ref()); + } + if font_changed || tool_data.lorem_ipsum_preview_metric_typesetting != Some(metric_typesetting) { + tool_data.lorem_ipsum_preview_metric_typesetting = Some(metric_typesetting); + tool_data.lorem_ipsum_preview_average_advance = average_glyph_advance(&font_resource, typesetting); + } } let word_count = lorem_ipsum_word_count(constraint_size, tool_data.lorem_ipsum_preview_average_advance, typesetting); let text = tool_data.lorem_ipsum_preview(word_count); - let position = start.min(end); let (align, align_last) = tool_options.align.css(); responses.add(FrontendMessage::DisplayEditableTextbox { @@ -873,8 +884,8 @@ impl Fsm for TextToolFsmState { line_height_ratio: typesetting.line_height_ratio, font_size: tool_options.font_size, color: tool_options.fill.active_color().map_or(COLOR_OVERLAY_BLACK.to_string(), |color| SRGBA8::from(color).to_css_hex()), - font_data: Vec::new().into(), - transform: window_aligned_transform(document, position, DVec2::ONE).to_cols_array(), + font_data: font_data.into(), + transform: window_aligned_transform(document, start, DVec2::ONE).to_cols_array(), max_width: constraint_size.map(|size| size.x), max_height: constraint_size.map(|size| size.y), align: align.to_string(), @@ -1029,7 +1040,6 @@ impl Fsm for TextToolFsmState { if let Some(bounds) = &mut tool_data.bounding_box_manager { bounds.original_transforms.clear(); } - tool_data.reset_lorem_ipsum_preview(); TextToolFsmState::Ready } @@ -1048,8 +1058,11 @@ impl Fsm for TextToolFsmState { let typesetting = new_text_typesetting(tool_options, constraint_size); let text = if transition_data.preferences.lorem_ipsum_placeholder { tool_data.is_lorem_ipsum = true; - let font_resource = fonts.get_resource_or_queue_load(&tool_options.font, responses); - let word_count = lorem_ipsum_word_count(constraint_size, average_glyph_advance(&font_resource, typesetting), typesetting); + let average_advance = constraint_size.and_then(|_| { + let font_resource = fonts.get_resource_or_queue_load(&tool_options.font, responses); + average_glyph_advance(&font_resource, typesetting) + }); + let word_count = lorem_ipsum_word_count(constraint_size, average_advance, typesetting); generate_lorem_ipsum(word_count) } else { tool_data.is_lorem_ipsum = false; @@ -1058,7 +1071,7 @@ impl Fsm for TextToolFsmState { tool_data.reset_lorem_ipsum_preview(); let editing_text = EditingText { text, - transform: window_aligned_transform(document, start.min(end), DVec2::ONE), + transform: window_aligned_transform(document, start, DVec2::ONE), typesetting, font: Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()), color: tool_options.fill.active_color(), @@ -1082,7 +1095,6 @@ impl Fsm for TextToolFsmState { return TextToolFsmState::Editing; } tool_data.layer_dragging.take(); - tool_data.reset_lorem_ipsum_preview(); TextToolFsmState::Ready } @@ -1099,9 +1111,8 @@ impl Fsm for TextToolFsmState { tool_data.new_text = new_text; if !is_left_or_right_click { - let was_lorem_ipsum = tool_data.is_lorem_ipsum; fit_lorem_ipsum_to_height(tool_data, fonts, responses); - if was_lorem_ipsum && tool_data.new_text.is_empty() { + if tool_data.is_lorem_ipsum && tool_data.new_text.is_empty() { return tool_data.delete_empty_layer(fonts, responses); } tool_data.is_lorem_ipsum = false; @@ -1256,7 +1267,7 @@ fn lorem_ipsum_word_count(constraint_size: Option, average_advance: Optio } fn generate_lorem_ipsum(word_count: usize) -> String { - let word_count = word_count.clamp(DEFAULT_LOREM_IPSUM_WORD_COUNT, MAX_LOREM_IPSUM_WORD_COUNT); + let word_count = word_count.clamp(1, MAX_LOREM_IPSUM_WORD_COUNT); let mut rng = rand::rngs::StdRng::seed_from_u64(LOREM_IPSUM_SEED); ipsum::generate(word_count, ipsum::Unit::Words, word_count, ipsum::Unit::Words, |length| rng.random_range(0..length)) } diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 1692573b498..5c7d1748bf7 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -371,7 +371,7 @@ // 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`; + else textInput.textContent = data.editable ? `${data.text}\n` : data.text; // Make it so `maxHeight` is a multiple of `lineHeight` const lineHeight = data.lineHeightRatio * data.fontSize; @@ -388,11 +388,6 @@ 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) { @@ -404,6 +399,11 @@ } if (data.editable) { + textInput.oninput = () => { + if (!textInput) return; + editor.updateBounds(textInputCleanup(textInput.innerText)); + }; + // 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); @@ -705,7 +705,7 @@ {@html artworkSvg} {/if} -
+
{#if showTextInput}
{/if} From 51d4eef39daa9fd3a05c72a0a7900f6f21e1c440 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 21 Sep 2026 04:44:02 +0530 Subject: [PATCH 5/6] chore: review --- .../messages/tool/tool_messages/text_tool.rs | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index d22485d5fcc..17975a9eb29 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -713,19 +713,19 @@ impl Fsm for TextToolFsmState { TextToolFsmState::Editing } - (_, TextToolMessage::Overlays { context: mut overlay_context }) => { - if matches!(self, Self::Placing) { - // Get the updated selection box bounds - let quad = Quad::from_box(tool_data.cached_resize_bounds); - - // Draw a bounding box on the layers to be selected - for layer in document.intersect_quad_no_artboards(quad, viewport) { - overlay_context.quad(Quad::from_box(document.metadata().bounding_box_viewport(layer).unwrap_or([DVec2::ZERO; 2])), None, Some(fill_color)); - } + (TextToolFsmState::Placing, TextToolMessage::Overlays { context: mut overlay_context }) => { + // Get the updated selection box bounds + let quad = Quad::from_box(tool_data.cached_resize_bounds); - overlay_context.quad(quad, None, Some(fill_color)); + // Draw bounding boxes on the layers to be selected + for layer in document.intersect_quad_no_artboards(quad, viewport) { + overlay_context.quad(Quad::from_box(document.metadata().bounding_box_viewport(layer).unwrap_or([DVec2::ZERO; 2])), None, Some(fill_color)); } + overlay_context.quad(quad, None, Some(fill_color)); + self + } + (_, TextToolMessage::Overlays { context: mut overlay_context }) => { // TODO: implement bounding box for multiple layers let selected = document.network_interface.selected_nodes(); let mut all_layers = selected.selected_visible_and_unlocked_layers(&document.network_interface); @@ -876,6 +876,7 @@ impl Fsm for TextToolFsmState { } let word_count = lorem_ipsum_word_count(constraint_size, tool_data.lorem_ipsum_preview_average_advance, typesetting); let text = tool_data.lorem_ipsum_preview(word_count); + let position = start.min(end); let (align, align_last) = tool_options.align.css(); responses.add(FrontendMessage::DisplayEditableTextbox { @@ -885,7 +886,7 @@ impl Fsm for TextToolFsmState { font_size: tool_options.font_size, color: tool_options.fill.active_color().map_or(COLOR_OVERLAY_BLACK.to_string(), |color| SRGBA8::from(color).to_css_hex()), font_data: font_data.into(), - transform: window_aligned_transform(document, start, DVec2::ONE).to_cols_array(), + transform: window_aligned_transform(document, position, DVec2::ONE).to_cols_array(), max_width: constraint_size.map(|size| size.x), max_height: constraint_size.map(|size| size.y), align: align.to_string(), @@ -1071,7 +1072,7 @@ impl Fsm for TextToolFsmState { tool_data.reset_lorem_ipsum_preview(); let editing_text = EditingText { text, - transform: window_aligned_transform(document, start, DVec2::ONE), + transform: window_aligned_transform(document, start.min(end), DVec2::ONE), typesetting, font: Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()), color: tool_options.fill.active_color(), From 94fc489fc159cf27351269999ac663864fd9d34f Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 21 Sep 2026 04:54:38 +0530 Subject: [PATCH 6/6] chore: review --- editor/src/messages/tool/tool_messages/text_tool.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 17975a9eb29..c356f28d923 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -458,6 +458,7 @@ struct TextToolData { new_text: String, is_lorem_ipsum: bool, last_lorem_ipsum_constraint: Option, + last_lorem_ipsum_position: Option, lorem_ipsum_preview: String, lorem_ipsum_preview_word_count: usize, lorem_ipsum_preview_font_hash: Option, @@ -648,6 +649,7 @@ impl TextToolData { fn reset_lorem_ipsum_preview(&mut self) { self.last_lorem_ipsum_constraint = None; + self.last_lorem_ipsum_position = None; self.lorem_ipsum_preview = String::new(); self.lorem_ipsum_preview_word_count = 0; self.lorem_ipsum_preview_font_hash = None; @@ -855,8 +857,10 @@ impl Fsm for TextToolFsmState { let has_dragged = (start - end).length_squared() > DRAG_THRESHOLD * DRAG_THRESHOLD; let constraint_size = has_dragged.then_some((start - end).abs() / viewport_zoom(document)); let floor_constraint = constraint_size.map(|c| c.floor()); + let position = start.min(end); if tool_data.last_lorem_ipsum_constraint != floor_constraint { tool_data.last_lorem_ipsum_constraint = floor_constraint; + tool_data.last_lorem_ipsum_position = Some(position); let typesetting = new_text_typesetting(tool_options, constraint_size); let mut font_data = Vec::new(); @@ -876,7 +880,6 @@ impl Fsm for TextToolFsmState { } let word_count = lorem_ipsum_word_count(constraint_size, tool_data.lorem_ipsum_preview_average_advance, typesetting); let text = tool_data.lorem_ipsum_preview(word_count); - let position = start.min(end); let (align, align_last) = tool_options.align.css(); responses.add(FrontendMessage::DisplayEditableTextbox { @@ -892,6 +895,11 @@ impl Fsm for TextToolFsmState { align: align.to_string(), align_last: align_last.to_string(), }); + } else if tool_data.last_lorem_ipsum_position.is_some_and(|last_position| last_position != position) { + tool_data.last_lorem_ipsum_position = Some(position); + responses.add(FrontendMessage::DisplayEditableTextboxTransform { + transform: window_aligned_transform(document, position, DVec2::ONE).to_cols_array(), + }); } }