From ebdb83589062fc52aba29b275f4f255ad9ef67ff Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 2 May 2026 21:04:30 -0700 Subject: [PATCH] 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 --- .../portfolio/document/document_message.rs | 3 ++ .../document/document_message_handler.rs | 13 +++++ .../document/graph_operation/utility_types.rs | 29 ++++++++--- .../utility_types/document_metadata.rs | 9 ++++ .../utility_types/network_interface.rs | 7 +++ .../messages/portfolio/document_migration.rs | 12 +++++ .../graph_modification_utils.rs | 2 +- .../common_functionality/utility_functions.rs | 20 ++++---- .../tool/tool_messages/select_tool.rs | 8 ++-- .../messages/tool/tool_messages/text_tool.rs | 25 ++++------ editor/src/node_graph_executor.rs | 2 + node-graph/libraries/core-types/src/lib.rs | 4 +- node-graph/libraries/core-types/src/table.rs | 6 +++ .../libraries/rendering/src/renderer.rs | 16 +++++-- node-graph/nodes/gstd/src/text.rs | 6 +-- node-graph/nodes/text/src/path_builder.rs | 48 +++++++++++++++---- node-graph/nodes/text/src/text_context.rs | 18 ++++++- 17 files changed, 169 insertions(+), 59 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index 06ceff36..f84f6a37 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -218,6 +218,9 @@ pub enum DocumentMessage { UpdateOutlines { outlines: HashMap>>, }, + UpdateTextFrames { + text_frames: HashMap, + }, UpdateClipTargets { clip_targets: HashSet, }, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 824b61f8..d6ccfc2e 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1283,6 +1283,19 @@ impl MessageHandler> 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); } diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 6af981a9..0dc0fa67 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -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); } diff --git a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs index 38b45ac8..45208025 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -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>>, + /// 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, pub clip_targets: HashSet, pub vector_modify: HashMap, /// 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)? diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 486def16..b17883e1 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -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) { + 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) { self.document_metadata.clip_targets = new_clip_targets; diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 296fdd4a..5ad65069 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -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)); diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 93bb62a2..9c62fcf7 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -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; }; diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index a277a87b..cedc6540 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -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 { diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index d61515e8..5baf8138 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -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) { - 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); } diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index cb969d31..3e28fc8e 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -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 { - 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>>, + /// Per-layer text frame from row 0's `editor:text_frame` attribute. + /// The Text tool composes this with `transform_to_viewport(layer)` to position its drag cage. + pub text_frames: HashMap, pub clip_targets: HashSet, pub vector_data: HashMap>, pub backgrounds: Vec, @@ -349,6 +352,7 @@ impl RenderMetadata { first_element_source_id, click_targets, outlines, + text_frames, clip_targets, vector_data, backgrounds, @@ -358,6 +362,7 @@ impl RenderMetadata { first_element_source_id.extend(other.first_element_source_id.iter()); click_targets.extend(other.click_targets.iter().map(|(k, v)| (*k, v.clone()))); outlines.extend(other.outlines.iter().map(|(k, v)| (*k, v.clone()))); + text_frames.extend(other.text_frames.iter()); clip_targets.extend(other.clip_targets.iter()); vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone()))); @@ -1366,7 +1371,7 @@ impl Render for Table { } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) { - // Aggregate all items' targets per element_id so multi-item tables (e.g. 'Text' node with "Separate Glyph Elements" active) produce hit areas for every glyph. + // Aggregate all items' targets per element_id so multi-item tables (e.g. 'Text' node with "Separate Glyphs" active) produce hit areas for every glyph. // Targets are baked relative to item 0's transform since `Graphic::collect_metadata` records that as `local_transforms[element_id]`. let item_zero_transform: DAffine2 = if !self.is_empty() { self.attribute_cloned_or_default(ATTR_TRANSFORM, 0) @@ -1414,6 +1419,11 @@ impl Render for Table { // Source geometry (not the click-target override) so editing tools work on letterforms. // Only item 0 is recorded since editing tools can only target a single item currently. metadata.vector_data.entry(element_id).or_insert_with(|| Arc::new(source.clone())); + + // Surface `editor:text_frame` for the Text tool's drag cage + if let Some(&frame) = self.attribute::(ATTR_EDITOR_TEXT_FRAME, index) { + metadata.text_frames.entry(element_id).or_insert(frame); + } } // If this item carries a snapshot of upstream graphic content (e.g. it was produced by Boolean Operation, diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 436993a9..b9365b87 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -59,8 +59,8 @@ fn text<'i: 'n>( /// To have an effect on a single line of text, *Max Width* must be set. #[widget(ParsedWidgetOverride::Custom = "text_align")] align: TextAlign, - /// Whether to split every letterform into its own vector path element. Otherwise, a single compound path is produced. - separate_glyph_elements: bool, + /// Whether to split every letterform into its own vector item. Otherwise, a single vector compound path is produced. + separate_glyphs: bool, ) -> Table { let typesetting = TypesettingConfig { font_size: size, @@ -72,5 +72,5 @@ fn text<'i: 'n>( align, }; - to_path(&text, &font, &editor_resources.font_cache, typesetting, separate_glyph_elements) + to_path(&text, &font, &editor_resources.font_cache, typesetting, separate_glyphs) } diff --git a/node-graph/nodes/text/src/path_builder.rs b/node-graph/nodes/text/src/path_builder.rs index 178c0c35..f8ea84c5 100644 --- a/node-graph/nodes/text/src/path_builder.rs +++ b/node-graph/nodes/text/src/path_builder.rs @@ -1,5 +1,5 @@ use core_types::table::{Table, TableRow}; -use core_types::{ATTR_EDITOR_CLICK_TARGET, ATTR_TRANSFORM}; +use core_types::{ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_TEXT_FRAME, ATTR_TRANSFORM}; use glam::{DAffine2, DVec2}; use parley::GlyphRun; use skrifa::GlyphId; @@ -15,19 +15,26 @@ pub struct PathBuilder { origin: DVec2, glyph_subpaths: Vec>, pub vector_table: Table, - /// Per-glyph bbox rectangles collected in single-row mode, published as `ATTR_EDITOR_CLICK_TARGET` in `finalize()`. + /// Per-glyph bbox rectangles collected in single-item mode, published as `ATTR_EDITOR_CLICK_TARGET` in `finalize()`. merged_click_target_subpaths: Vec>, + /// Text frame size, stamped per item as `ATTR_EDITOR_TEXT_FRAME` relative to each item's origin. + text_frame_size: DVec2, + /// First glyph's baseline offset (pre-height-filter). Used for the empty placeholder item so + /// `local_transforms` stays stable when all glyphs are clipped during a resize drag. + first_glyph_offset: DVec2, scale: f64, id: PointId, } impl PathBuilder { - pub fn new(per_glyph_items: bool, scale: f64) -> Self { + pub fn new(per_glyph_items: bool, scale: f64, text_frame_size: DVec2, first_glyph_offset: DVec2) -> Self { Self { current_subpath: Subpath::new(Vec::new(), false), glyph_subpaths: Vec::new(), vector_table: if per_glyph_items { Table::new() } else { Table::new_from_element(Vector::default()) }, merged_click_target_subpaths: Vec::new(), + text_frame_size, + first_glyph_offset, scale, id: PointId::ZERO, origin: DVec2::default(), @@ -58,12 +65,18 @@ impl PathBuilder { let glyph_bbox_rectangle = subpaths_bounding_box(&self.glyph_subpaths).map(|[min, max]| Subpath::new_rectangle(min, max)); if per_glyph_items { - let row = TableRow::new_from_element(Vector::from_subpaths(core::mem::take(&mut self.glyph_subpaths), false)).with_attribute(ATTR_TRANSFORM, DAffine2::from_translation(glyph_offset)); - let row = match glyph_bbox_rectangle { - Some(rect) => row.with_attribute(ATTR_EDITOR_CLICK_TARGET, Vector::from_subpaths([rect], false)), - None => row, + // Frame in item-local space: top-left at `-glyph_offset` so the item transform cancels it + // back to the layer-local frame origin, regardless of which glyph survived + let frame_in_item_local = DAffine2::from_scale_angle_translation(self.text_frame_size, 0., -glyph_offset); + + let item = TableRow::new_from_element(Vector::from_subpaths(core::mem::take(&mut self.glyph_subpaths), false)) + .with_attribute(ATTR_TRANSFORM, DAffine2::from_translation(glyph_offset)) + .with_attribute(ATTR_EDITOR_TEXT_FRAME, frame_in_item_local); + let item = match glyph_bbox_rectangle { + Some(rect) => item.with_attribute(ATTR_EDITOR_CLICK_TARGET, Vector::from_subpaths([rect], false)), + None => item, }; - self.vector_table.push(row); + self.vector_table.push(item); } else { for subpath in self.glyph_subpaths.drain(..) { // Unwrapping here is ok because `self.vector_table` is initialized with a single `Table` item @@ -130,16 +143,31 @@ impl PathBuilder { } pub fn finalize(mut self) -> Table { + // Empty table = all glyphs clipped by height. Create a placeholder with the same item-0 + // transform a populated table would have so `local_transforms` stays stable mid-drag. + // TODO: Remove this hack and move the attribute up to the parent return value when is done. if self.vector_table.is_empty() { - self.vector_table = Table::new_from_element(Vector::default()); + let frame_in_item_local = DAffine2::from_scale_angle_translation(self.text_frame_size, 0., -self.first_glyph_offset); + let item = TableRow::new_from_element(Vector::default()) + .with_attribute(ATTR_TRANSFORM, DAffine2::from_translation(self.first_glyph_offset)) + .with_attribute(ATTR_EDITOR_TEXT_FRAME, frame_in_item_local); + self.vector_table.push(item); } - // With "Separate Glyph Elements" inactive, combine the accumulated per-glyph AABBs as one override `Vector` + // With "Separate Glyphs" inactive, combine the accumulated per-glyph AABBs as one override `Vector` if !self.merged_click_target_subpaths.is_empty() { self.vector_table .set_attribute(ATTR_EDITOR_CLICK_TARGET, 0, Vector::from_subpaths(self.merged_click_target_subpaths, false)); } + // Fill in text frame for items that don't have one yet (single-item mode, where item 0 = identity) + let frame = DAffine2::from_scale(self.text_frame_size); + for index in 0..self.vector_table.len() { + if self.vector_table.attribute::(ATTR_EDITOR_TEXT_FRAME, index).is_none() { + self.vector_table.set_attribute(ATTR_EDITOR_TEXT_FRAME, index, frame); + } + } + self.vector_table } } diff --git a/node-graph/nodes/text/src/text_context.rs b/node-graph/nodes/text/src/text_context.rs index 5527e1a9..cc16ea8f 100644 --- a/node-graph/nodes/text/src/text_context.rs +++ b/node-graph/nodes/text/src/text_context.rs @@ -92,7 +92,23 @@ impl TextContext { return Table::new_from_element(Vector::default()); }; - let mut path_builder = PathBuilder::new(per_glyph_items, layout.scale() as f64); + let text_frame_size = DVec2::new( + typesetting.max_width.unwrap_or_else(|| layout.full_width() as f64), + typesetting.max_height.unwrap_or_else(|| layout.height() as f64), + ); + + // First glyph offset (pre-height-filter) so the empty placeholder item in `per_glyph_items` + // mode keeps the same item 0's transform, preventing `local_transforms` from jumping mid-drag + let first_glyph_offset = layout + .lines() + .flat_map(|line| line.items()) + .find_map(|item| match item { + PositionedLayoutItem::GlyphRun(run) => run.glyphs().next().map(|glyph| DVec2::new((run.offset() + glyph.x) as f64, (run.baseline() - glyph.y) as f64)), + _ => None, + }) + .unwrap_or_default(); + + let mut path_builder = PathBuilder::new(per_glyph_items, layout.scale() as f64, text_frame_size, first_glyph_offset); for line in layout.lines() { for item in line.items() {