diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index da7373f2..fee0c5ec 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -15,7 +15,7 @@ use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary; use graph_craft::document::NodeId; use graphene_std::raster::Image; use graphene_std::raster::color::Color; -use graphene_std::text::{Font, TextAlign}; +use graphene_std::text::Font; use graphene_std::vector::style::FillChoice; use std::path::PathBuf; @@ -51,7 +51,9 @@ pub enum FrontendMessage { max_width: Option, #[serde(rename = "maxHeight")] max_height: Option, - align: TextAlign, + align: String, + #[serde(rename = "alignLast")] + align_last: String, }, DisplayEditableTextboxUpdateFontData { #[serde(rename = "fontData")] 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 49b9fef0..91df5586 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 @@ -1728,7 +1728,9 @@ impl<'a> MessageHandler> for NodeG } NodeGraphMessage::SetInputValue { node_id, input_index, value } => { let is_fill = matches!(value, TaggedValue::Fill(_)); - let is_text_align = matches!(value, TaggedValue::TextAlign(_)); + let is_text_node = network_interface + .reference(&node_id, selection_network_path) + .is_some_and(|reference| reference == DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)); let input = NodeInput::value(value, false); responses.add(NodeGraphMessage::SetInput { input_connector: InputConnector::node(node_id, input_index), @@ -1738,7 +1740,7 @@ impl<'a> MessageHandler> for NodeG if is_fill { responses.add(OverlaysMessage::Draw); } - if is_text_align { + if is_text_node { responses.add(TextToolMessage::SelectionChanged); } if network_interface.connected_to_output(&node_id, selection_network_path) { diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 11a59f8e..e8481870 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -860,6 +860,8 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec Self { Self { font_size: 24., - line_height_ratio: 1.2, character_spacing: 0., font: Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into()), fill: ToolColorOptions::new_primary(), @@ -84,7 +82,6 @@ pub enum TextOptionsUpdate { FillColorType(ToolColorType), Font { font: Font }, FontSize(f64), - LineHeightRatio(f64), Align(TextAlign), WorkingColors(Option, Option), } @@ -101,36 +98,63 @@ impl ToolMetadata for TextTool { } } -fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec { - fn update_options(font: Font, commit_style: Option) -> impl Fn(&()) -> Message + Clone { - let mut font = font; - if let Some(style) = commit_style { - font.font_style = style; - } +fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog, document: &DocumentMessageHandler) -> Vec { + // If a single text layer is selected, the toolbar's font/style menus drive that layer's text node directly, going through the + // same code path as the Properties panel (LoadFontData + SetInputValue, with closest_style and font_style_to_restore bookkeeping). + // Otherwise the menus only update the toolbar option for the next created text. + let text_node_id = can_edit_selected(document).and_then(|layer| graph_modification_utils::get_text_id(layer, &document.network_interface)); - move |_| { - TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::Font { font: font.clone() }, + let font_input_index = graphene_std::text::text::FontInput::INDEX; + let apply_font = move |new_font: Font| -> Message { + match text_node_id { + Some(node_id) => NodeGraphMessage::SetInputValue { + node_id, + input_index: font_input_index, + value: TaggedValue::Font(new_font), } - .into() + .into(), + None => TextToolMessage::UpdateOptions { + options: TextOptionsUpdate::Font { font: new_font }, + } + .into(), } - } + }; + let preview_font = move |new_font: Font| -> Message { + Message::Batched { + messages: Box::new([PortfolioMessage::LoadFontData { font: new_font.clone() }.into(), apply_font(new_font)]), + } + }; + let commit_font = move |new_font: Font| -> Message { + match text_node_id { + Some(_) => DeferMessage::AfterGraphRun { + messages: vec![apply_font(new_font), DocumentMessage::AddTransaction.into()], + } + .into(), + None => apply_font(new_font), + } + }; let font = DropdownInput::new(vec![ font_catalog .0 .iter() .map(|family| { - let font = Font::new(family.name.clone(), tool.options.font.font_style.clone()); - let commit_style = font_catalog.find_font_style_in_catalog(&tool.options.font).map(|style| style.to_named_style()); - let update = update_options(font.clone(), None); - let commit = update_options(font, commit_style); + let current_font = &tool.options.font; + let mut new_font = Font::new(family.name.clone(), current_font.font_style_to_restore.clone().unwrap_or_else(|| current_font.font_style.clone())); + new_font.font_style_to_restore = current_font.font_style_to_restore.clone().or_else(|| Some(new_font.font_style.clone())); + let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&new_font.font_style, ""); + new_font.font_style = family.closest_style(weight, italic).to_named_style(); + + // Intentionally drop `font_style_to_restore` on commit so the committed style becomes the new basis for + // subsequent family switches. Preserving the original style intent is hover-only behavior (handled by `new_font`). + let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(¤t_font.font_style, ""); + let commit_only_font = Font::new(family.name.clone(), family.closest_style(weight, italic).to_named_style()); MenuListEntry::new(family.name.clone()) .label(family.name.clone()) .font(family.closest_style(400, false).preview_url(&family.name)) - .on_update(update) - .on_commit(commit) + .on_update(move |_| preview_font(new_font.clone())) + .on_commit(move |_| commit_font(commit_only_font.clone())) }) .collect::>(), ]) @@ -146,13 +170,14 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec Vec = TextAlign::list() .iter() .flat_map(|section| section.iter()) @@ -229,29 +241,29 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec, cached_data: &CachedData) { - self.send_layout(responses, LayoutTarget::ToolOptions, &cached_data.font_catalog); + fn refresh_options(&self, responses: &mut VecDeque, _cached_data: &CachedData) { + // Defer to the SelectionChanged handler which has document context, required for the font/style + // dropdowns to bind to the selected text layer's node graph inputs + responses.add(TextToolMessage::SelectionChanged); } } impl TextTool { - fn send_layout(&self, responses: &mut VecDeque, layout_target: LayoutTarget, font_catalog: &FontCatalog) { + fn send_layout(&self, responses: &mut VecDeque, layout_target: LayoutTarget, font_catalog: &FontCatalog, document: &DocumentMessageHandler) { responses.add(LayoutMessage::SendLayout { - layout: self.layout(font_catalog), + layout: self.layout(font_catalog, document), layout_target, }); } - fn layout(&self, font_catalog: &FontCatalog) -> Layout { - let mut widgets = create_text_widgets(self, font_catalog); + fn layout(&self, font_catalog: &FontCatalog, document: &DocumentMessageHandler) -> Layout { + let mut widgets = create_text_widgets(self, font_catalog, document); widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); @@ -291,14 +303,18 @@ impl<'a> MessageHandler> for Text ToolMessage::Text(TextToolMessage::UpdateOptions { options }) => options, ToolMessage::Text(TextToolMessage::SelectionChanged) => { if let Some(layer) = can_edit_selected(context.document) - && let Some((_, _, typesetting, _)) = graph_modification_utils::get_text(layer, &context.document.network_interface) + && let Some((_, font, typesetting, _)) = graph_modification_utils::get_text(layer, &context.document.network_interface) { self.options.align = typesetting.align; + self.options.font_size = typesetting.font_size; + self.options.font = font.clone(); if let Some(editing_text) = self.tool_data.editing_text.as_mut() { editing_text.typesetting.align = typesetting.align; + editing_text.typesetting.font_size = typesetting.font_size; + editing_text.font = font.clone(); } } - self.send_layout(responses, LayoutTarget::ToolOptions, &context.cached_data.font_catalog); + self.send_layout(responses, LayoutTarget::ToolOptions, &context.cached_data.font_catalog, context.document); return; } _ => { @@ -308,10 +324,28 @@ impl<'a> MessageHandler> for Text }; match options { TextOptionsUpdate::Font { font } => { - self.options.font = font; + // The toolbar font/style menus go through `SetInputValue` directly when a text layer is selected, so this + // arm only fires when no layer is selected (toolbar font is just the default for the next-created text). + self.options.font = font.clone(); + if let Some(editing_text) = self.tool_data.editing_text.as_mut() { + editing_text.font = font; + } + } + TextOptionsUpdate::FontSize(font_size) => { + self.options.font_size = font_size; + if let Some(editing_text) = self.tool_data.editing_text.as_mut() { + editing_text.typesetting.font_size = font_size; + } + if let Some(layer) = can_edit_selected(context.document) + && let Some(node_id) = graph_modification_utils::get_text_id(layer, &context.document.network_interface) + { + responses.add(NodeGraphMessage::SetInputValue { + node_id, + input_index: graphene_std::text::text::SizeInput::INDEX, + value: TaggedValue::F64(font_size), + }); + } } - TextOptionsUpdate::FontSize(font_size) => self.options.font_size = font_size, - TextOptionsUpdate::LineHeightRatio(line_height_ratio) => self.options.line_height_ratio = line_height_ratio, TextOptionsUpdate::Align(align) => { self.options.align = align; if let Some(editing_text) = self.tool_data.editing_text.as_mut() { @@ -320,11 +354,11 @@ impl<'a> MessageHandler> for Text if let Some(layer) = can_edit_selected(context.document) && let Some(node_id) = graph_modification_utils::get_text_id(layer, &context.document.network_interface) { - responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, graphene_std::text::text::AlignInput::INDEX), - input: NodeInput::value(TaggedValue::TextAlign(align), false), + responses.add(NodeGraphMessage::SetInputValue { + node_id, + input_index: graphene_std::text::text::AlignInput::INDEX, + value: TaggedValue::TextAlign(align), }); - responses.add(NodeGraphMessage::RunDocumentGraph); } } TextOptionsUpdate::FillColor(color) => { @@ -338,7 +372,7 @@ impl<'a> MessageHandler> for Text } } - self.send_layout(responses, LayoutTarget::ToolOptions, &context.cached_data.font_catalog); + self.send_layout(responses, LayoutTarget::ToolOptions, &context.cached_data.font_catalog, context.document); } fn actions(&self) -> ActionList { @@ -446,6 +480,7 @@ impl TextToolData { /// Set the editing state of the currently modifying layer fn set_editing(&self, editable: bool, font_cache: &FontCache, responses: &mut VecDeque) { if let Some(editing_text) = self.editing_text.as_ref().filter(|_| editable) { + let (align, align_last) = editing_text.typesetting.align.css(); responses.add(FrontendMessage::DisplayEditableTextbox { text: editing_text.text.clone(), line_height_ratio: editing_text.typesetting.line_height_ratio, @@ -455,7 +490,8 @@ impl TextToolData { transform: editing_text.transform.to_cols_array(), max_width: editing_text.typesetting.max_width, max_height: editing_text.typesetting.max_height, - align: editing_text.typesetting.align, + align: align.to_string(), + align_last: align_last.to_string(), }); } else { // Check if DisplayRemoveEditableTextbox is already in the responses queue @@ -930,12 +966,12 @@ impl Fsm for TextToolFsmState { transform: DAffine2::from_translation(start), typesetting: TypesettingConfig { font_size: tool_options.font_size, - line_height_ratio: tool_options.line_height_ratio, max_width: constraint_size.map(|size| size.x), character_spacing: tool_options.character_spacing, max_height: constraint_size.map(|size| size.y), tilt: tool_options.tilt, align: tool_options.align, + ..TypesettingConfig::default() }, font: Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()), color: tool_options.fill.active_color(), diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 94623d1b..cb9c6edd 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -380,6 +380,7 @@ 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; diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index a9605780..c0dd59e3 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -71,6 +71,19 @@ impl TextAlign { _ => None, } } + + /// CSS `(text-align, text-align-last)` values approximating this alignment for the `contenteditable` text overlay. + pub fn css(self) -> (&'static str, &'static str) { + match self { + Self::AlignLeft => ("left", "auto"), + Self::AlignCenter => ("center", "auto"), + Self::AlignRight => ("right", "auto"), + Self::JustifyLeft => ("justify", "auto"), + Self::JustifyCenter => ("justify", "center"), + Self::JustifyRight => ("justify", "right"), + Self::JustifyAll => ("justify", "justify"), + } + } } #[derive(PartialEq, Clone, Copy, Debug)]