Properly track the Text node's text frame via the attribute system to fix misalignment (#4097)

* Compensate for upstream row-0 transform absorption in viewport-space 'TransformSet'

* Add 'editor:text_frame' row attribute so the Text tool's drag cage tracks multi-row text

* "Separate Glyph Elements" -> "Separate Glyphs"

* Improve artboard migration robustness from older documents

* Code review

* Make the tools visualize the text frame based on attribute not upstream node
This commit is contained in:
Keavon Chambers
2026-05-02 21:04:30 -07:00
committed by GitHub
parent de2ae29edd
commit ebdb835890
17 changed files with 169 additions and 59 deletions

View File

@@ -218,6 +218,9 @@ pub enum DocumentMessage {
UpdateOutlines {
outlines: HashMap<NodeId, Vec<Arc<ClickTarget>>>,
},
UpdateTextFrames {
text_frames: HashMap<NodeId, DAffine2>,
},
UpdateClipTargets {
clip_targets: HashSet<NodeId>,
},

View File

@@ -1283,6 +1283,19 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
.collect();
self.network_interface.update_outlines(layer_outlines);
}
DocumentMessage::UpdateTextFrames { text_frames } => {
let layer_text_frames = text_frames
.into_iter()
.filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id))
.filter_map(|(node_id, frame)| {
self.network_interface.is_layer(&node_id, &[]).then(|| {
let layer = LayerNodeIdentifier::new(node_id, &self.network_interface);
(layer, frame)
})
})
.collect();
self.network_interface.update_text_frames(layer_text_frames);
}
DocumentMessage::UpdateClipTargets { clip_targets } => {
self.network_interface.update_clip_targets(clip_targets);
}

View File

@@ -652,15 +652,30 @@ impl<'a> ModifyInputsContext<'a> {
// Get the existing upstream Transform node, if present
let transform_node_id = self.existing_network_node_id("Transform", false);
// Get a transform appropriate for the requested space
let to_transform = match transform_in {
TransformIn::Local => DAffine2::IDENTITY,
TransformIn::Scope { scope } => scope,
TransformIn::Viewport => self.network_interface.document_metadata().downstream_transform_to_viewport(self.layer_node.unwrap()).inverse(),
// Compute the Transform node value so `transform_to_viewport` matches the target after re-render
let final_transform = match transform_in {
TransformIn::Local => transform,
TransformIn::Scope { scope } => scope * transform,
TransformIn::Viewport => {
let Some(layer) = self.layer_node else { return };
let metadata = self.network_interface.document_metadata();
let parent_inverse = metadata.downstream_transform_to_viewport(layer).inverse();
// Compensate for item 0's baseline offset (multi-item Text only) so the layer doesn't jump by it.
// Gated on `text_frames` because metadata can be stale mid-handler.
if metadata.text_frames.contains_key(&layer) {
let local_transform = metadata.local_transforms.get(&layer.to_node()).copied().unwrap_or(DAffine2::IDENTITY);
let current_transform_node_value = transform_node_id
.and_then(|id| self.network_interface.document_network().nodes.get(&id))
.map(|node| transform_utils::get_current_transform(&node.inputs))
.unwrap_or(DAffine2::IDENTITY);
parent_inverse * transform * local_transform.inverse() * current_transform_node_value
} else {
parent_inverse * transform
}
}
};
// Set the transform value to the Transform node
let final_transform = to_transform * transform;
self.transform_set_direct(final_transform, skip_rerender, transform_node_id);
}

View File

@@ -31,6 +31,9 @@ pub struct DocumentMetadata {
/// Source-geometry outlines for hover/selection overlays, separate from `click_targets` so
/// nodes with an `editor:click_target` override still outline the precise geometry.
pub outlines: HashMap<LayerNodeIdentifier, Vec<Arc<ClickTarget>>>,
/// Per-layer text frame from item 0's `editor:text_frame` attribute (`DAffine2` mapping the (0, 0)-(1, 1) unit square onto the frame).
/// The Text tool composes this with `transform_to_viewport(layer)` to position its drag cage.
pub text_frames: HashMap<LayerNodeIdentifier, DAffine2>,
pub clip_targets: HashSet<NodeId>,
pub vector_modify: HashMap<NodeId, Vector>,
/// Vector data keyed by layer ID, used as fallback when no Path node exists.
@@ -163,6 +166,12 @@ impl DocumentMetadata {
self.outlines.get(&layer).or_else(|| self.click_targets.get(&layer)).map(|v| v.as_slice())
}
/// Whether to treat this layer as a text layer for tool UI. Checks the surfaced `editor:text_frame`
/// metadata rather than upstream topology, so layers that strip the attribute downstream correctly drop out.
pub fn is_text_layer(&self, layer: LayerNodeIdentifier) -> bool {
self.text_frames.contains_key(&layer)
}
/// Get the bounding box of the click target of the specified layer in the specified transform space
pub fn bounding_box_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2) -> Option<[DVec2; 2]> {
self.visual_targets(layer)?

View File

@@ -3277,6 +3277,7 @@ impl NodeNetworkInterface {
self.document_metadata.vector_modify.retain(|node, _| nodes.contains(node));
self.document_metadata.click_targets.retain(|layer, _| self.document_metadata.structure.contains_key(layer));
self.document_metadata.outlines.retain(|layer, _| self.document_metadata.structure.contains_key(layer));
self.document_metadata.text_frames.retain(|layer, _| self.document_metadata.structure.contains_key(layer));
}
/// Update the cached transforms of the layers
@@ -3300,6 +3301,12 @@ impl NodeNetworkInterface {
self.document_metadata.outlines = new_outlines;
}
/// Update the cached per-layer 'Text' node text frames in row-local space (as `DAffine2`
/// mapping the unit square onto the frame).
pub fn update_text_frames(&mut self, new_text_frames: HashMap<LayerNodeIdentifier, DAffine2>) {
self.document_metadata.text_frames = new_text_frames;
}
/// Update the cached clip targets of the layers
pub fn update_clip_targets(&mut self, new_clip_targets: HashSet<NodeId>) {
self.document_metadata.clip_targets = new_clip_targets;

View File

@@ -1086,6 +1086,18 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
document.network_interface.replace_implementation(node_id, network_path, &mut node_definition.default_node_template());
}
// Rebuild stale Merge/Artboard subgraphs that still use the removed LegacyLayerExtendNode internally
if let DocumentNodeImplementation::Network(inner) = &node.implementation
&& inner
.nodes
.values()
.any(|n| matches!(&n.implementation, DocumentNodeImplementation::ProtoNode(id) if id.as_str().contains("LegacyLayerExtend") || id.as_str().contains("legacy_layer_extend")))
&& let Some(reference) = document.network_interface.reference(node_id, network_path)
&& let Some(node_definition) = resolve_document_node_type(&reference)
{
document.network_interface.replace_implementation(node_id, network_path, &mut node_definition.default_node_template());
}
// Upgrade old nodes to use `Context` instead of `()` or `Footprint` as their call argument
if node.call_argument == graph_craft::concrete!(()) || node.call_argument == graph_craft::concrete!(graphene_std::transform::Footprint) {
document.network_interface.set_call_argument(node_id, network_path, graph_craft::concrete!(graphene_std::Context));

View File

@@ -466,7 +466,7 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter
let Some(&TaggedValue::TextAlign(align)) = inputs[graphene_std::text::text::AlignInput::INDEX].as_value() else {
return None;
};
let Some(&TaggedValue::Bool(per_glyph_items)) = inputs[graphene_std::text::text::SeparateGlyphElementsInput::INDEX].as_value() else {
let Some(&TaggedValue::Bool(per_glyph_items)) = inputs[graphene_std::text::text::SeparateGlyphsInput::INDEX].as_value() else {
return None;
};

View File

@@ -64,21 +64,17 @@ where
/// Calculates the bounding box of the layer's text, based on the settings for max width and height specified in the typesetting config.
pub fn text_bounding_box(layer: LayerNodeIdentifier, document: &DocumentMessageHandler, font_cache: &FontCache) -> Quad {
let Some((text, font, typesetting, per_glyph_items)) = get_text(layer, &document.network_interface) else {
// Use the `editor:text_frame` attribute if available (handles multi-item glyphs and the 'Index Elements' node)
if let Some(&frame) = document.metadata().text_frames.get(&layer) {
return frame * Quad::from_box([DVec2::ZERO, DVec2::ONE]);
}
// Fallback: recompute from text content (e.g. layer hasn't rendered yet)
let Some((text, font, typesetting, _)) = get_text(layer, &document.network_interface) else {
return Quad::from_box([DVec2::ZERO, DVec2::ZERO]);
};
let far = graphene_std::text::bounding_box(text, font, font_cache, typesetting, false);
// TODO: Once the `Table` refactor is complete and per_glyph_items can be removed (since it'll be the default),
// TODO: remove this because the top of the dashed bounding overlay should no longer be based on the first line's baseline.
let vertical_offset = if per_glyph_items {
DVec2::NEG_Y * typesetting.font_size * (1. + (typesetting.line_height_ratio - 1.) / 2.)
} else {
DVec2::ZERO
};
Quad::from_box([DVec2::ZERO + vertical_offset, far + vertical_offset])
Quad::from_box([DVec2::ZERO, far])
}
pub fn calculate_segment_angle(anchor: PointId, segment: SegmentId, vector: &Vector, prefer_handle_direction: bool) -> Option<f64> {

View File

@@ -4,7 +4,6 @@ use super::tool_prelude::*;
use crate::consts::*;
use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition;
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier};
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, GroupFolderType};
@@ -14,7 +13,6 @@ use crate::messages::preferences::SelectionMode;
use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
use crate::messages::tool::common_functionality::compass_rose::{Axis, CompassRose};
use crate::messages::tool::common_functionality::graph_modification_utils;
use crate::messages::tool::common_functionality::graph_modification_utils::is_layer_fed_by_node_of_name;
use crate::messages::tool::common_functionality::measure;
use crate::messages::tool::common_functionality::pivot::{PivotGizmo, PivotGizmoType, PivotToolSource, pin_pivot_widget, pivot_gizmo_type_widget, pivot_reference_point_widget};
use crate::messages::tool::common_functionality::shape_editor::SelectionShapeType;
@@ -631,7 +629,7 @@ impl Fsm for SelectToolFsmState {
let layer_to_viewport = document.metadata().transform_to_viewport(layer);
overlay_context.outline(document.metadata().layer_with_free_points_outline(layer), layer_to_viewport, None);
if is_layer_fed_by_node_of_name(layer, &document.network_interface, &DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)) {
if document.metadata().is_text_layer(layer) {
let transformed_quad = layer_to_viewport * text_bounding_box(layer, document, &cached_data.font_cache);
overlay_context.dashed_quad(transformed_quad, None, None, Some(7.), Some(5.), None);
}
@@ -1574,7 +1572,7 @@ impl Fsm for SelectToolFsmState {
if let Some(layer) = selected_layers.next() {
// Check that only one layer is selected
if selected_layers.next().is_none() && is_layer_fed_by_node_of_name(layer, &document.network_interface, &DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)) {
if selected_layers.next().is_none() && document.metadata().is_text_layer(layer) {
responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Text });
responses.add(TextToolMessage::EditSelected);
}
@@ -1934,7 +1932,7 @@ fn edit_layer_shallowest_manipulation(document: &DocumentMessageHandler, layer:
/// Called when a double click on a layer in deep select mode.
/// If the layer is text, the text tool is selected.
fn edit_layer_deepest_manipulation(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface, responses: &mut VecDeque<Message>) {
if is_layer_fed_by_node_of_name(layer, network_interface, &DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)) {
if network_interface.document_metadata().is_text_layer(layer) {
responses.add_front(ToolMessage::ActivateTool { tool_type: ToolType::Text });
responses.add(TextToolMessage::EditSelected);
}

View File

@@ -3,14 +3,13 @@
use super::tool_prelude::*;
use crate::consts::{COLOR_OVERLAY_BLUE_05, COLOR_OVERLAY_RED, DRAG_THRESHOLD};
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::InputConnector;
use crate::messages::portfolio::utility_types::{CachedData, FontCatalog, FontCatalogStyle};
use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, ToolColorType};
use crate::messages::tool::common_functionality::graph_modification_utils::{self, is_layer_fed_by_node_of_name};
use crate::messages::tool::common_functionality::graph_modification_utils;
use crate::messages::tool::common_functionality::resize::Resize;
use crate::messages::tool::common_functionality::snapping::{self, SnapCandidatePoint, SnapData};
use crate::messages::tool::common_functionality::transformation_cage::*;
@@ -518,16 +517,12 @@ impl TextToolData {
}
fn check_click(document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, font_cache: &FontCache) -> Option<LayerNodeIdentifier> {
document
.metadata()
.all_layers()
.filter(|&layer| is_layer_fed_by_node_of_name(layer, &document.network_interface, &DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)))
.find(|&layer| {
let transformed_quad = document.metadata().transform_to_viewport(layer) * text_bounding_box(layer, document, font_cache);
let mouse = DVec2::new(input.mouse.position.x, input.mouse.position.y);
document.metadata().all_layers().filter(|&layer| document.metadata().is_text_layer(layer)).find(|&layer| {
let transformed_quad = document.metadata().transform_to_viewport(layer) * text_bounding_box(layer, document, font_cache);
let mouse = DVec2::new(input.mouse.position.x, input.mouse.position.y);
transformed_quad.contains(mouse)
})
transformed_quad.contains(mouse)
})
}
fn get_snap_candidates(&mut self, document: &DocumentMessageHandler, font_cache: &FontCache) {
@@ -550,7 +545,7 @@ fn can_edit_selected(document: &DocumentMessageHandler) -> Option<LayerNodeIdent
return None;
}
if !is_layer_fed_by_node_of_name(layer, &document.network_interface, &DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)) {
if !document.metadata().is_text_layer(layer) {
return None;
}
@@ -612,7 +607,7 @@ impl Fsm for TextToolFsmState {
// 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);
let layer = all_layers.find(|layer| is_layer_fed_by_node_of_name(*layer, &document.network_interface, &DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)));
let layer = all_layers.find(|&layer| document.metadata().is_text_layer(layer));
let bounds = layer.map(|layer| text_bounding_box(layer, document, font_cache));
let layer_transform = layer.map(|layer| document.metadata().transform_to_viewport(layer)).unwrap_or(DAffine2::IDENTITY);
@@ -673,7 +668,7 @@ impl Fsm for TextToolFsmState {
let selected = document.network_interface.selected_nodes();
let mut all_selected = selected.selected_visible_and_unlocked_layers(&document.network_interface);
let selected = all_selected.find(|layer| is_layer_fed_by_node_of_name(*layer, &document.network_interface, &DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)));
let selected = all_selected.find(|&layer| document.metadata().is_text_layer(layer));
if dragging_bounds.is_some() {
responses.add(DocumentMessage::StartTransaction);
@@ -712,7 +707,7 @@ impl Fsm for TextToolFsmState {
// This ensures the cursor only changes if a layer is selected
let selected = document.network_interface.selected_nodes();
let mut all_selected = selected.selected_visible_and_unlocked_layers(&document.network_interface);
let layer = all_selected.find(|&layer| is_layer_fed_by_node_of_name(layer, &document.network_interface, &DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)));
let layer = all_selected.find(|&layer| document.metadata().is_text_layer(layer));
let mut cursor = tool_data
.bounding_box_manager

View File

@@ -418,6 +418,7 @@ impl NodeGraphExecutor {
first_element_source_id,
click_targets,
outlines,
text_frames,
clip_targets,
vector_data,
backgrounds: _,
@@ -431,6 +432,7 @@ impl NodeGraphExecutor {
});
responses.add(DocumentMessage::UpdateClickTargets { click_targets });
responses.add(DocumentMessage::UpdateOutlines { outlines });
responses.add(DocumentMessage::UpdateTextFrames { text_frames });
responses.add(DocumentMessage::UpdateClipTargets { clip_targets });
responses.add(DocumentMessage::UpdateVectorData { vector_data });
responses.add(DocumentMessage::RenderScrollbars);