Make the Text tool control bar font family, style, and size sync with the selected layer (#4118)

* Fix contenteditable preview alignment

* Make the Text tool control bar font family, style, and size sync with the selected layer

* Tidying up
This commit is contained in:
Keavon Chambers
2026-05-06 20:50:41 -07:00
committed by GitHub
parent 2ae35a67e7
commit 24c857ddc7
6 changed files with 118 additions and 62 deletions
@@ -15,7 +15,7 @@ use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary;
use graph_craft::document::NodeId; use graph_craft::document::NodeId;
use graphene_std::raster::Image; use graphene_std::raster::Image;
use graphene_std::raster::color::Color; use graphene_std::raster::color::Color;
use graphene_std::text::{Font, TextAlign}; use graphene_std::text::Font;
use graphene_std::vector::style::FillChoice; use graphene_std::vector::style::FillChoice;
use std::path::PathBuf; use std::path::PathBuf;
@@ -51,7 +51,9 @@ pub enum FrontendMessage {
max_width: Option<f64>, max_width: Option<f64>,
#[serde(rename = "maxHeight")] #[serde(rename = "maxHeight")]
max_height: Option<f64>, max_height: Option<f64>,
align: TextAlign, align: String,
#[serde(rename = "alignLast")]
align_last: String,
}, },
DisplayEditableTextboxUpdateFontData { DisplayEditableTextboxUpdateFontData {
#[serde(rename = "fontData")] #[serde(rename = "fontData")]
@@ -1728,7 +1728,9 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
} }
NodeGraphMessage::SetInputValue { node_id, input_index, value } => { NodeGraphMessage::SetInputValue { node_id, input_index, value } => {
let is_fill = matches!(value, TaggedValue::Fill(_)); 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); let input = NodeInput::value(value, false);
responses.add(NodeGraphMessage::SetInput { responses.add(NodeGraphMessage::SetInput {
input_connector: InputConnector::node(node_id, input_index), input_connector: InputConnector::node(node_id, input_index),
@@ -1738,7 +1740,7 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
if is_fill { if is_fill {
responses.add(OverlaysMessage::Draw); responses.add(OverlaysMessage::Draw);
} }
if is_text_align { if is_text_node {
responses.add(TextToolMessage::SelectionChanged); responses.add(TextToolMessage::SelectionChanged);
} }
if network_interface.connected_to_output(&node_id, selection_network_path) { if network_interface.connected_to_output(&node_id, selection_network_path) {
@@ -860,6 +860,8 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec<WidgetI
let font_style = family.closest_style(weight, italic).to_named_style(); let font_style = family.closest_style(weight, italic).to_named_style();
move |_| { move |_| {
// 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.
let new_font = Font::new(font_family.clone(), font_style.clone()); let new_font = Font::new(font_family.clone(), font_style.clone());
DeferMessage::AfterGraphRun { DeferMessage::AfterGraphRun {
@@ -32,7 +32,6 @@ pub struct TextTool {
pub struct TextOptions { pub struct TextOptions {
font_size: f64, font_size: f64,
line_height_ratio: f64,
character_spacing: f64, character_spacing: f64,
font: Font, font: Font,
fill: ToolColorOptions, fill: ToolColorOptions,
@@ -44,7 +43,6 @@ impl Default for TextOptions {
fn default() -> Self { fn default() -> Self {
Self { Self {
font_size: 24., font_size: 24.,
line_height_ratio: 1.2,
character_spacing: 0., character_spacing: 0.,
font: Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into()), font: Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into()),
fill: ToolColorOptions::new_primary(), fill: ToolColorOptions::new_primary(),
@@ -84,7 +82,6 @@ pub enum TextOptionsUpdate {
FillColorType(ToolColorType), FillColorType(ToolColorType),
Font { font: Font }, Font { font: Font },
FontSize(f64), FontSize(f64),
LineHeightRatio(f64),
Align(TextAlign), Align(TextAlign),
WorkingColors(Option<Color>, Option<Color>), WorkingColors(Option<Color>, Option<Color>),
} }
@@ -101,36 +98,63 @@ impl ToolMetadata for TextTool {
} }
} }
fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec<WidgetInstance> { fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog, document: &DocumentMessageHandler) -> Vec<WidgetInstance> {
fn update_options(font: Font, commit_style: Option<String>) -> impl Fn(&()) -> Message + Clone { // If a single text layer is selected, the toolbar's font/style menus drive that layer's text node directly, going through the
let mut font = font; // same code path as the Properties panel (LoadFontData + SetInputValue, with closest_style and font_style_to_restore bookkeeping).
if let Some(style) = commit_style { // Otherwise the menus only update the toolbar option for the next created text.
font.font_style = style; let text_node_id = can_edit_selected(document).and_then(|layer| graph_modification_utils::get_text_id(layer, &document.network_interface));
}
move |_| { let font_input_index = graphene_std::text::text::FontInput::INDEX;
TextToolMessage::UpdateOptions { let apply_font = move |new_font: Font| -> Message {
options: TextOptionsUpdate::Font { font: font.clone() }, 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![ let font = DropdownInput::new(vec![
font_catalog font_catalog
.0 .0
.iter() .iter()
.map(|family| { .map(|family| {
let font = Font::new(family.name.clone(), tool.options.font.font_style.clone()); let current_font = &tool.options.font;
let commit_style = font_catalog.find_font_style_in_catalog(&tool.options.font).map(|style| style.to_named_style()); let mut new_font = Font::new(family.name.clone(), current_font.font_style_to_restore.clone().unwrap_or_else(|| current_font.font_style.clone()));
let update = update_options(font.clone(), None); new_font.font_style_to_restore = current_font.font_style_to_restore.clone().or_else(|| Some(new_font.font_style.clone()));
let commit = update_options(font, commit_style); 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(&current_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()) MenuListEntry::new(family.name.clone())
.label(family.name.clone()) .label(family.name.clone())
.font(family.closest_style(400, false).preview_url(&family.name)) .font(family.closest_style(400, false).preview_url(&family.name))
.on_update(update) .on_update(move |_| preview_font(new_font.clone()))
.on_commit(commit) .on_commit(move |_| commit_font(commit_only_font.clone()))
}) })
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
]) ])
@@ -146,13 +170,14 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec<Widge
.map(|family| { .map(|family| {
let build_entry = |style: &FontCatalogStyle| { let build_entry = |style: &FontCatalogStyle| {
let font_style = style.to_named_style(); let font_style = style.to_named_style();
let new_font = Font::new(tool.options.font.font_family.clone(), font_style.clone());
let font = Font::new(tool.options.font.font_family.clone(), font_style.clone()); let new_font_for_commit = new_font.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);
MenuListEntry::new(font_style.clone()).on_update(update).on_commit(commit).label(font_style) MenuListEntry::new(font_style.clone())
.label(font_style)
.on_update(move |_| preview_font(new_font.clone()))
.on_commit(move |_| commit_font(new_font_for_commit.clone()))
}; };
vec![ vec![
@@ -192,19 +217,6 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec<Widge
.into() .into()
}) })
.widget_instance(); .widget_instance();
let line_height_ratio = NumberInput::new(Some(tool.options.line_height_ratio))
.label("Line Height")
.int()
.min(0.)
.max((1_u64 << f64::MANTISSA_DIGITS) as f64)
.step(0.1)
.on_update(|number_input: &NumberInput| {
TextToolMessage::UpdateOptions {
options: TextOptionsUpdate::LineHeightRatio(number_input.value.unwrap()),
}
.into()
})
.widget_instance();
let align_entries: Vec<_> = TextAlign::list() let align_entries: Vec<_> = TextAlign::list()
.iter() .iter()
.flat_map(|section| section.iter()) .flat_map(|section| section.iter())
@@ -229,29 +241,29 @@ fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog) -> Vec<Widge
style, style,
Separator::new(SeparatorStyle::Related).widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(),
size, size,
Separator::new(SeparatorStyle::Related).widget_instance(),
line_height_ratio,
Separator::new(SeparatorStyle::Unrelated).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(),
align, align,
] ]
} }
impl ToolRefreshOptions for TextTool { impl ToolRefreshOptions for TextTool {
fn refresh_options(&self, responses: &mut VecDeque<Message>, cached_data: &CachedData) { fn refresh_options(&self, responses: &mut VecDeque<Message>, _cached_data: &CachedData) {
self.send_layout(responses, LayoutTarget::ToolOptions, &cached_data.font_catalog); // 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 { impl TextTool {
fn send_layout(&self, responses: &mut VecDeque<Message>, layout_target: LayoutTarget, font_catalog: &FontCatalog) { fn send_layout(&self, responses: &mut VecDeque<Message>, layout_target: LayoutTarget, font_catalog: &FontCatalog, document: &DocumentMessageHandler) {
responses.add(LayoutMessage::SendLayout { responses.add(LayoutMessage::SendLayout {
layout: self.layout(font_catalog), layout: self.layout(font_catalog, document),
layout_target, layout_target,
}); });
} }
fn layout(&self, font_catalog: &FontCatalog) -> Layout { fn layout(&self, font_catalog: &FontCatalog, document: &DocumentMessageHandler) -> Layout {
let mut widgets = create_text_widgets(self, font_catalog); let mut widgets = create_text_widgets(self, font_catalog, document);
widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); widgets.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
@@ -291,14 +303,18 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Text
ToolMessage::Text(TextToolMessage::UpdateOptions { options }) => options, ToolMessage::Text(TextToolMessage::UpdateOptions { options }) => options,
ToolMessage::Text(TextToolMessage::SelectionChanged) => { ToolMessage::Text(TextToolMessage::SelectionChanged) => {
if let Some(layer) = can_edit_selected(context.document) 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.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() { if let Some(editing_text) = self.tool_data.editing_text.as_mut() {
editing_text.typesetting.align = typesetting.align; 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; return;
} }
_ => { _ => {
@@ -308,10 +324,28 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Text
}; };
match options { match options {
TextOptionsUpdate::Font { font } => { 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) => { TextOptionsUpdate::Align(align) => {
self.options.align = align; self.options.align = align;
if let Some(editing_text) = self.tool_data.editing_text.as_mut() { if let Some(editing_text) = self.tool_data.editing_text.as_mut() {
@@ -320,11 +354,11 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Text
if let Some(layer) = can_edit_selected(context.document) if let Some(layer) = can_edit_selected(context.document)
&& let Some(node_id) = graph_modification_utils::get_text_id(layer, &context.document.network_interface) && let Some(node_id) = graph_modification_utils::get_text_id(layer, &context.document.network_interface)
{ {
responses.add(NodeGraphMessage::SetInput { responses.add(NodeGraphMessage::SetInputValue {
input_connector: InputConnector::node(node_id, graphene_std::text::text::AlignInput::INDEX), node_id,
input: NodeInput::value(TaggedValue::TextAlign(align), false), input_index: graphene_std::text::text::AlignInput::INDEX,
value: TaggedValue::TextAlign(align),
}); });
responses.add(NodeGraphMessage::RunDocumentGraph);
} }
} }
TextOptionsUpdate::FillColor(color) => { TextOptionsUpdate::FillColor(color) => {
@@ -338,7 +372,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> 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 { fn actions(&self) -> ActionList {
@@ -446,6 +480,7 @@ impl TextToolData {
/// Set the editing state of the currently modifying layer /// Set the editing state of the currently modifying layer
fn set_editing(&self, editable: bool, font_cache: &FontCache, responses: &mut VecDeque<Message>) { fn set_editing(&self, editable: bool, font_cache: &FontCache, responses: &mut VecDeque<Message>) {
if let Some(editing_text) = self.editing_text.as_ref().filter(|_| editable) { 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 { responses.add(FrontendMessage::DisplayEditableTextbox {
text: editing_text.text.clone(), text: editing_text.text.clone(),
line_height_ratio: editing_text.typesetting.line_height_ratio, line_height_ratio: editing_text.typesetting.line_height_ratio,
@@ -455,7 +490,8 @@ impl TextToolData {
transform: editing_text.transform.to_cols_array(), transform: editing_text.transform.to_cols_array(),
max_width: editing_text.typesetting.max_width, max_width: editing_text.typesetting.max_width,
max_height: editing_text.typesetting.max_height, max_height: editing_text.typesetting.max_height,
align: editing_text.typesetting.align, align: align.to_string(),
align_last: align_last.to_string(),
}); });
} else { } else {
// Check if DisplayRemoveEditableTextbox is already in the responses queue // Check if DisplayRemoveEditableTextbox is already in the responses queue
@@ -930,12 +966,12 @@ impl Fsm for TextToolFsmState {
transform: DAffine2::from_translation(start), transform: DAffine2::from_translation(start),
typesetting: TypesettingConfig { typesetting: TypesettingConfig {
font_size: tool_options.font_size, font_size: tool_options.font_size,
line_height_ratio: tool_options.line_height_ratio,
max_width: constraint_size.map(|size| size.x), max_width: constraint_size.map(|size| size.x),
character_spacing: tool_options.character_spacing, character_spacing: tool_options.character_spacing,
max_height: constraint_size.map(|size| size.y), max_height: constraint_size.map(|size| size.y),
tilt: tool_options.tilt, tilt: tool_options.tilt,
align: tool_options.align, align: tool_options.align,
..TypesettingConfig::default()
}, },
font: Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()), font: Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()),
color: tool_options.fill.active_color(), color: tool_options.fill.active_color(),
@@ -380,6 +380,7 @@
textInput.style.fontSize = `${data.fontSize}px`; textInput.style.fontSize = `${data.fontSize}px`;
textInput.style.color = data.color; textInput.style.color = data.color;
textInput.style.textAlign = data.align; textInput.style.textAlign = data.align;
textInput.style.textAlignLast = data.alignLast;
textInput.oninput = () => { textInput.oninput = () => {
if (!textInput) return; if (!textInput) return;
+13
View File
@@ -71,6 +71,19 @@ impl TextAlign {
_ => None, _ => 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)] #[derive(PartialEq, Clone, Copy, Debug)]