From 325e9aff06d8644d495ba618e8e36fcb86894857 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 2 May 2026 18:12:42 -0700 Subject: [PATCH] Add custom click targets to make Text node output easier to select (#4095) * Stop pushing duplicate layer entries when re-clicking an already-selected layer * Make Text node generate per-glyph bounding box click targets * Show source-geometry outlines and aggregate all rows for layer click targets * Strip 'editor:click_target' override on Path node so direct edits restore precise hit testing * Fix inverting a zero-determinate transform --- .../portfolio/document/document_message.rs | 3 + .../document/document_message_handler.rs | 13 ++ .../utility_types/document_metadata.rs | 21 +- .../utility_types/network_interface.rs | 6 + .../tool/tool_messages/select_tool.rs | 10 +- editor/src/node_graph_executor.rs | 4 + node-graph/libraries/core-types/src/lib.rs | 4 +- node-graph/libraries/core-types/src/table.rs | 17 ++ .../libraries/rendering/src/renderer.rs | 218 ++++++++++++------ node-graph/nodes/text/src/path_builder.rs | 33 ++- .../vector/src/vector_modification_nodes.rs | 5 +- 11 files changed, 245 insertions(+), 89 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index 5ed4931f..06ceff36 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -215,6 +215,9 @@ pub enum DocumentMessage { UpdateClickTargets { click_targets: HashMap>>, }, + UpdateOutlines { + outlines: 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 9cc930ff..3903d86d 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1270,6 +1270,19 @@ impl MessageHandler> for DocumentMes .collect(); self.network_interface.update_click_targets(layer_click_targets); } + DocumentMessage::UpdateOutlines { outlines } => { + let layer_outlines = outlines + .into_iter() + .filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id)) + .filter_map(|(node_id, outlines)| { + self.network_interface.is_layer(&node_id, &[]).then(|| { + let layer = LayerNodeIdentifier::new(node_id, &self.network_interface); + (layer, outlines) + }) + }) + .collect(); + self.network_interface.update_outlines(layer_outlines); + } DocumentMessage::UpdateClipTargets { clip_targets } => { self.network_interface.update_clip_targets(clip_targets); } 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 4787ebf0..98b3cd35 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -28,6 +28,9 @@ pub struct DocumentMetadata { pub first_element_source_ids: HashMap>, pub structure: HashMap, pub click_targets: HashMap>>, + /// 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>>, pub clip_targets: HashSet, pub vector_modify: HashMap, /// Vector data keyed by layer ID, used as fallback when no Path node exists. @@ -154,9 +157,15 @@ impl DocumentMetadata { // =============================== impl DocumentMetadata { + /// Outline targets if present, otherwise click targets. Used for bounding boxes and outline + /// drawing so layers with an `editor:click_target` override report precise geometry bounds. + fn visual_targets(&self, layer: LayerNodeIdentifier) -> Option<&[Arc]> { + self.outlines.get(&layer).or_else(|| self.click_targets.get(&layer)).map(|v| v.as_slice()) + } + /// 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.click_targets(layer)? + self.visual_targets(layer)? .iter() .filter_map(|click_target| click_target.bounding_box_with_transform(transform)) .reduce(Quad::combine_bounds) @@ -164,7 +173,7 @@ impl DocumentMetadata { /// Get the loose bounding box of the click target of the specified layer in the specified transform space pub fn loose_bounding_box_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2) -> Option<[DVec2; 2]> { - self.click_targets(layer)? + self.visual_targets(layer)? .iter() .filter_map(|click_target| match click_target.target_type() { ClickTargetType::Subpath(subpath) => subpath.loose_bounding_box_with_transform(transform), @@ -210,18 +219,14 @@ impl DocumentMetadata { } pub fn layer_outline(&self, layer: LayerNodeIdentifier) -> impl Iterator> { - static EMPTY: Vec> = Vec::new(); - let click_targets = self.click_targets.get(&layer).unwrap_or(&EMPTY); - click_targets.iter().filter_map(|target| match target.target_type() { + self.visual_targets(layer).unwrap_or(&[]).iter().filter_map(|target| match target.target_type() { ClickTargetType::Subpath(subpath) => Some(subpath), _ => None, }) } pub fn layer_with_free_points_outline(&self, layer: LayerNodeIdentifier) -> impl Iterator { - static EMPTY: Vec> = Vec::new(); - let click_targets = self.click_targets.get(&layer).unwrap_or(&EMPTY); - click_targets.iter().map(|target| target.target_type()) + self.visual_targets(layer).unwrap_or(&[]).iter().map(|target| target.target_type()) } pub fn is_clip(&self, node: NodeId) -> bool { 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 0ba6ceb5..486def16 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -3276,6 +3276,7 @@ impl NodeNetworkInterface { self.document_metadata.local_transforms.retain(|node, _| nodes.contains(node)); 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)); } /// Update the cached transforms of the layers @@ -3294,6 +3295,11 @@ impl NodeNetworkInterface { self.document_metadata.click_targets = new_click_targets; } + /// Update the cached source-geometry outline targets of the layers + pub fn update_outlines(&mut self, new_outlines: HashMap>>) { + self.document_metadata.outlines = new_outlines; + } + /// 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/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index 4202e374..d61515e8 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -1823,7 +1823,10 @@ fn drag_shallowest_manipulation(responses: &mut VecDeque, selected: Vec } let new_selected = final_selection.unwrap_or_else(|| clicked_layer.ancestors(document.metadata()).filter(not_artboard(document)).last().unwrap_or(clicked_layer)); - tool_data.layers_dragging.extend(vec![new_selected]); + // Duplicates cause `SelectedNodesSet` to carry the layer twice, breaking the Data panel's single-selection check in `node_to_inspect` + if !tool_data.layers_dragging.contains(&new_selected) { + tool_data.layers_dragging.push(new_selected); + } tool_data.layers_dragging.retain(|&selected_layer| !selected_layer.is_child_of(metadata, &new_selected)); if remove { tool_data.layers_dragging.retain(|&selected_layer| clicked_layer != selected_layer); @@ -1885,7 +1888,10 @@ fn drag_deepest_manipulation(responses: &mut VecDeque, selected: Vec { // Clear the click targets while the graph is in an un-renderable state document.network_interface.update_click_targets(HashMap::new()); + document.network_interface.update_outlines(HashMap::new()); document.network_interface.update_vector_modify(HashMap::new()); return Err(format!("Node graph evaluation failed:\n{e}")); } @@ -355,6 +356,7 @@ impl NodeGraphExecutor { // Clear the click targets while the graph is in an un-renderable state document.network_interface.update_click_targets(HashMap::new()); + document.network_interface.update_outlines(HashMap::new()); document.network_interface.update_vector_modify(HashMap::new()); log::trace!("{e}"); @@ -415,6 +417,7 @@ impl NodeGraphExecutor { local_transforms, first_element_source_id, click_targets, + outlines, clip_targets, vector_data, backgrounds: _, @@ -427,6 +430,7 @@ impl NodeGraphExecutor { first_element_source_id, }); responses.add(DocumentMessage::UpdateClickTargets { click_targets }); + responses.add(DocumentMessage::UpdateOutlines { outlines }); responses.add(DocumentMessage::UpdateClipTargets { clip_targets }); responses.add(DocumentMessage::UpdateVectorData { vector_data }); responses.add(DocumentMessage::RenderScrollbars); diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 686fd09c..cf61f4ee 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -34,8 +34,8 @@ use std::any::TypeId; use std::future::Future; use std::pin::Pin; pub use table::{ - ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_END, ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_NAME, - ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_START, ATTR_TRANSFORM, ATTR_TYPE, + ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_END, ATTR_GRADIENT_TYPE, + ATTR_LOCATION, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_START, ATTR_TRANSFORM, ATTR_TYPE, }; #[cfg(feature = "wasm")] pub use tsify; diff --git a/node-graph/libraries/core-types/src/table.rs b/node-graph/libraries/core-types/src/table.rs index 6a1f5429..fa344443 100644 --- a/node-graph/libraries/core-types/src/table.rs +++ b/node-graph/libraries/core-types/src/table.rs @@ -36,6 +36,11 @@ pub const ATTR_EDITOR_LAYER_PATH: &str = "editor:layer_path"; /// the original child layers after their content has been collapsed. pub const ATTR_EDITOR_MERGED_LAYERS: &str = "editor:merged_layers"; +/// Optional `Vector` that overrides the row's own geometry for click-target generation. +/// Used by the 'Text' node for per-glyph bounding-box rectangles so glyphs are selectable +/// by clicking anywhere within their bounds, not just the filled letterform. +pub const ATTR_EDITOR_CLICK_TARGET: &str = "editor:click_target"; + /// Byte offset where a regex match begins ('Regex Find All', 'Regex Capture' text nodes). pub const ATTR_START: &str = "start"; @@ -484,6 +489,13 @@ impl AttributeColumns { self.columns.iter().find_map(|(k, column)| if k == key { column.get_any(index)?.downcast_ref::() } else { None }) } + /// Removes the entire column for the given key, if present. + fn remove_column(&mut self, key: &str) { + if let Some(position) = self.columns.iter().position(|(k, _)| k == key) { + self.columns.remove(position); + } + } + /// Finds or creates a column for the given key and type, returning its position. /// If a column with the key exists but has the wrong type, it is removed and replaced with a new column of the correct type, padded with defaults. /// A newly created column is filled with `T::default()` for all existing rows. @@ -748,6 +760,11 @@ impl Table { self.attributes.set_value(key, index, value); } + /// Removes the entire attribute column for the given key, if present. + pub fn remove_attribute(&mut self, key: &str) { + self.attributes.remove_column(key); + } + /// Runs the given closure on a mutable reference to the attribute value at the given row index, /// creating the column with defaults if it doesn't exist, and returns the closure's result. pub fn with_attribute_mut_or_default R>(&mut self, key: &str, index: usize, f: F) -> R { diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 813803ec..933a0501 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -11,8 +11,8 @@ use core_types::table::{Table, TableRow}; use core_types::transform::Footprint; use core_types::uuid::{NodeId, generate_uuid}; use core_types::{ - ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_OPACITY, - ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, + ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_GRADIENT_TYPE, ATTR_LOCATION, + ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, }; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; @@ -324,6 +324,9 @@ pub struct RenderMetadata { pub local_transforms: HashMap, pub first_element_source_id: HashMap>, pub click_targets: HashMap>>, + /// 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>>, pub clip_targets: HashSet, pub vector_data: HashMap>, pub backgrounds: Vec, @@ -345,6 +348,7 @@ impl RenderMetadata { local_transforms, first_element_source_id, click_targets, + outlines, clip_targets, vector_data, backgrounds, @@ -353,6 +357,7 @@ impl RenderMetadata { local_transforms.extend(other.local_transforms.iter()); 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()))); clip_targets.extend(other.clip_targets.iter()); vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone()))); @@ -380,6 +385,11 @@ pub trait Render: BoundingBox + RenderComplexity { /// The upstream click targets for each layer are collected during the render so that they do not have to be calculated for each click detection. fn add_upstream_click_targets(&self, _click_targets: &mut Vec) {} + /// Like `add_upstream_click_targets` but for visual outlines. `Table` overrides this to ignore `editor:click_target` so outlines reflect the actual geometry. + fn add_upstream_outline_targets(&self, outlines: &mut Vec) { + self.add_upstream_click_targets(outlines); + } + // TODO: Store all click targets in a vec which contains the AABB, click target, and path // fn add_click_targets(&self, click_targets: &mut Vec<([DVec2; 2], ClickTarget, Vec)>, current_path: Option) {} @@ -490,6 +500,17 @@ impl Render for Graphic { } } + fn add_upstream_outline_targets(&self, outlines: &mut Vec) { + match self { + Graphic::Graphic(table) => table.add_upstream_outline_targets(outlines), + Graphic::Vector(table) => table.add_upstream_outline_targets(outlines), + Graphic::RasterCPU(table) => table.add_upstream_outline_targets(outlines), + Graphic::RasterGPU(table) => table.add_upstream_outline_targets(outlines), + Graphic::Color(table) => table.add_upstream_outline_targets(outlines), + Graphic::Gradient(table) => table.add_upstream_outline_targets(outlines), + } + } + fn contains_artboard(&self) -> bool { match self { Graphic::Graphic(table) => table.contains_artboard(), @@ -513,7 +534,7 @@ impl Render for Graphic { } } -/// Reads the artboard metadata for the row at `index` from a `Table`. +/// Reads the artboard metadata for the item at `index` from a `Table`. fn read_artboard_attributes(table: &Table, index: usize) -> (DVec2, DVec2, Color, bool) { let location: DVec2 = table.attribute_cloned_or_default(ATTR_LOCATION, index); let dimensions: DVec2 = table.attribute_cloned_or_default(ATTR_DIMENSIONS, index); @@ -711,8 +732,8 @@ impl Render for Table { let mut mask_element_and_transform = None; for index in 0..self.len() { - let row_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let transform = transform * row_transform; + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let transform = transform * item_transform; let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); @@ -787,13 +808,13 @@ impl Render for Table { fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { for index in 0..self.len() { - let row_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let layer_path: Table = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, index); let layer = layer_path.iter_element_values().next_back().copied(); let element = self.element(index).unwrap(); let mut footprint = footprint; - footprint.transform *= row_transform; + footprint.transform *= item_transform; if let Some(element_id) = layer { element.collect_metadata(metadata, footprint, Some(element_id)); @@ -805,40 +826,66 @@ impl Render for Table { if let Some(element_id) = element_id { let mut all_upstream_click_targets = Vec::new(); + let mut all_upstream_outlines = Vec::new(); for index in 0..self.len() { - let row_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let element = self.element(index).unwrap(); + let mut new_click_targets = Vec::new(); element.add_upstream_click_targets(&mut new_click_targets); for click_target in new_click_targets.iter_mut() { - click_target.apply_transform(row_transform) + click_target.apply_transform(item_transform) } all_upstream_click_targets.extend(new_click_targets); + + let mut new_outlines = Vec::new(); + element.add_upstream_outline_targets(&mut new_outlines); + for outline in new_outlines.iter_mut() { + outline.apply_transform(item_transform) + } + all_upstream_outlines.extend(new_outlines); } metadata.click_targets.insert(element_id, all_upstream_click_targets.into_iter().map(|x| x.into()).collect()); + metadata.outlines.insert(element_id, all_upstream_outlines.into_iter().map(|x| x.into()).collect()); } } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { for index in 0..self.len() { - let row_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let element = self.element(index).unwrap(); let mut new_click_targets = Vec::new(); element.add_upstream_click_targets(&mut new_click_targets); for click_target in new_click_targets.iter_mut() { - click_target.apply_transform(row_transform) + click_target.apply_transform(item_transform) } click_targets.extend(new_click_targets); } } + fn add_upstream_outline_targets(&self, outlines: &mut Vec) { + for index in 0..self.len() { + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let element = self.element(index).unwrap(); + let mut new_outlines = Vec::new(); + + element.add_upstream_outline_targets(&mut new_outlines); + + for outline in new_outlines.iter_mut() { + outline.apply_transform(item_transform) + } + + outlines.extend(new_outlines); + } + } + fn contains_artboard(&self) -> bool { self.iter_element_values().any(|element| element.contains_artboard()) } @@ -922,7 +969,7 @@ impl Render for Table { cloned_vector.style.clear_stroke(); cloned_vector.style.set_fill(Fill::solid(Color::BLACK)); - let vector_row = Table::new_from_row( + let vector_item = Table::new_from_row( TableRow::new_from_element(cloned_vector) .with_attribute(ATTR_TRANSFORM, multiplied_transform) .with_attribute(ATTR_BLEND_MODE, blend_mode_attr) @@ -931,7 +978,7 @@ impl Render for Table { .with_attribute(ATTR_CLIPPING_MASK, clipping_mask_attr), ); - (id, mask_type, vector_row) + (id, mask_type, vector_item) }); let use_face_fill = vector.use_face_fill(); @@ -969,9 +1016,9 @@ impl Render for Table { } let defs = &mut attributes.0.svg_defs; - if let Some((ref id, mask_type, ref vector_row)) = push_id { + if let Some((ref id, mask_type, ref vector_item)) = push_id { let mut svg = SvgRender::new(); - vector_row.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); + vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); let stroke = vector.style.stroke().unwrap(); let weight = stroke.effective_width() * max_scale(applied_stroke_transform); let quad = Quad::from_box(transformed_bounds).inflate(weight); @@ -1055,12 +1102,12 @@ impl Render for Table { use graphic_types::vector_types::vector; let Some(element) = self.element(index) else { continue }; - let row_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let clip_attr: bool = self.attribute_cloned_or_default(ATTR_CLIPPING_MASK, index); - let multiplied_transform = parent_transform * row_transform; + let multiplied_transform = parent_transform * item_transform; let has_real_stroke = element.style.stroke().filter(|stroke| stroke.weight() > 0.); let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.); let mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform); @@ -1246,7 +1293,7 @@ impl Render for Table { let vector_table = Table::new_from_row( TableRow::new_from_element(cloned_element) - .with_attribute(ATTR_TRANSFORM, row_transform) + .with_attribute(ATTR_TRANSFORM, item_transform) .with_attribute(ATTR_BLEND_MODE, blend_mode_attr) .with_attribute(ATTR_OPACITY, opacity_attr) .with_attribute(ATTR_OPACITY_FILL, opacity_fill_attr) @@ -1319,8 +1366,24 @@ 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. + // 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) + } else { + DAffine2::IDENTITY + }; + let item_zero_inverse = if item_zero_transform.matrix2.determinant() != 0. { + item_zero_transform.inverse() + } else { + DAffine2::IDENTITY + }; + + let mut accumulated_click_targets: HashMap>> = HashMap::new(); + let mut accumulated_outlines: HashMap>> = HashMap::new(); + for index in 0..self.len() { - let Some(vector) = self.element(index) else { continue }; + let Some(source) = self.element(index) else { continue }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let layer_path: Table = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, index); let layer = layer_path.iter_element_values().next_back().copied(); @@ -1331,40 +1394,26 @@ impl Render for Table { // normally provides but skipped due to the None element_id. if caller_element_id.is_none() { metadata.upstream_footprints.entry(element_id).or_insert(footprint); - metadata.local_transforms.entry(element_id).or_insert(transform); + metadata.local_transforms.entry(element_id).or_insert(item_zero_transform); } - let stroke_width = vector.style.stroke().as_ref().map_or(0., Stroke::effective_width); - let filled = vector.style.fill() != &Fill::None; - let fill = |mut subpath: Subpath<_>| { - if filled { - subpath.set_closed(true); - } - subpath - }; + // Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes) + let click_target_vector = self.attribute::(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source); - // For free-floating anchors, we need to add a click target for each - let single_anchors_targets = vector.point_domain.ids().iter().filter_map(|&point_id| { - if !vector.any_connected(point_id) { - let anchor = vector.point_domain.position_from_id(point_id).unwrap_or_default(); - let point = FreePoint::new(point_id, anchor); + let item_relative_transform = item_zero_inverse * transform; - Some(ClickTarget::new_with_free_point(point).into()) - } else { - None - } - }); + let mut click_targets_unwrapped = Vec::new(); + extend_targets_from_vector(&mut click_targets_unwrapped, click_target_vector, item_relative_transform); + accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new)); - let click_targets = vector - .stroke_bezier_paths() - .map(fill) - .map(|subpath| ClickTarget::new_with_subpath(subpath, stroke_width).into()) - .chain(single_anchors_targets) - .collect::>(); + // Outlines always use source geometry so the visual outline reflects actual letterforms + let mut outlines_unwrapped = Vec::new(); + extend_targets_from_vector(&mut outlines_unwrapped, source, item_relative_transform); + accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new)); - metadata.click_targets.entry(element_id).or_insert(click_targets); - // Store the full vector data including segment IDs for accurate segment modification - metadata.vector_data.entry(element_id).or_insert_with(|| Arc::new(vector.clone())); + // 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())); } // If this item carries a snapshot of upstream graphic content (e.g. it was produced by Boolean Operation, @@ -1377,41 +1426,35 @@ impl Render for Table { upstream_nested_layers.collect_metadata(metadata, upstream_footprint, None); } } + + // Overwrite with the full accumulated set (not just item 0's contribution) + for (element_id, targets) in accumulated_click_targets { + metadata.click_targets.insert(element_id, targets); + } + for (element_id, targets) in accumulated_outlines { + metadata.outlines.insert(element_id, targets); + } } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { for index in 0..self.len() { - let Some(vector) = self.element(index) else { continue }; + let Some(source) = self.element(index) else { continue }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let stroke_width = vector.style.stroke().as_ref().map_or(0., Stroke::effective_width); - let filled = vector.style.fill() != &Fill::None; - let fill = |mut subpath: Subpath<_>| { - if filled { - subpath.set_closed(true); - } - subpath - }; - click_targets.extend(vector.stroke_bezier_paths().map(fill).map(|subpath| { - let mut click_target = ClickTarget::new_with_subpath(subpath, stroke_width); - click_target.apply_transform(transform); - click_target - })); + // Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes) + let vector = self.attribute::(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source); - // For free-floating anchors, we need to add a click target for each - let single_anchors_targets = vector.point_domain.ids().iter().filter_map(|&point_id| { - if vector.any_connected(point_id) { - return None; - } + extend_targets_from_vector(click_targets, vector, transform); + } + } - let anchor = vector.point_domain.position_from_id(point_id).unwrap_or_default(); - let point = FreePoint::new(point_id, anchor); + fn add_upstream_outline_targets(&self, outlines: &mut Vec) { + // Source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms + for index in 0..self.len() { + let Some(source) = self.element(index) else { continue }; + let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let mut click_target = ClickTarget::new_with_free_point(point); - click_target.apply_transform(transform); - Some(click_target) - }); - click_targets.extend(single_anchors_targets); + extend_targets_from_vector(outlines, source, transform); } } @@ -1422,6 +1465,35 @@ impl Render for Table { } } +/// Build click targets (subpaths and free-floating anchors) from a `Vector`, apply the transform, and append to `targets`. +fn extend_targets_from_vector(targets: &mut Vec, vector: &Vector, transform: DAffine2) { + let stroke_width = vector.style.stroke().as_ref().map_or(0., Stroke::effective_width); + let filled = vector.style.fill() != &Fill::None; + let fill = |mut subpath: Subpath<_>| { + if filled { + subpath.set_closed(true); + } + subpath + }; + targets.extend(vector.stroke_bezier_paths().map(fill).map(|subpath| { + let mut click_target = ClickTarget::new_with_subpath(subpath, stroke_width); + click_target.apply_transform(transform); + click_target + })); + + let single_anchors = vector.point_domain.ids().iter().filter_map(|&point_id| { + if vector.any_connected(point_id) { + return None; + } + + let anchor = vector.point_domain.position_from_id(point_id).unwrap_or_default(); + let mut click_target = ClickTarget::new_with_free_point(FreePoint::new(point_id, anchor)); + click_target.apply_transform(transform); + Some(click_target) + }); + targets.extend(single_anchors); +} + impl Render for Table> { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { for index in 0..self.len() { diff --git a/node-graph/nodes/text/src/path_builder.rs b/node-graph/nodes/text/src/path_builder.rs index f0a1d0b3..178c0c35 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::ATTR_TRANSFORM; use core_types::table::{Table, TableRow}; +use core_types::{ATTR_EDITOR_CLICK_TARGET, ATTR_TRANSFORM}; use glam::{DAffine2, DVec2}; use parley::GlyphRun; use skrifa::GlyphId; @@ -15,6 +15,8 @@ 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()`. + merged_click_target_subpaths: Vec>, scale: f64, id: PointId, } @@ -25,6 +27,7 @@ impl PathBuilder { 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(), scale, id: PointId::ZERO, origin: DVec2::default(), @@ -51,14 +54,24 @@ impl PathBuilder { glyph_subpath.apply_transform(skew); } + // Bounding-box rectangle for click-targeting the glyph's full bounds (not just the letterform) + let glyph_bbox_rectangle = subpaths_bounding_box(&self.glyph_subpaths).map(|[min, max]| Subpath::new_rectangle(min, max)); + if per_glyph_items { - self.vector_table - .push(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 = 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, + }; + self.vector_table.push(row); } else { for subpath in self.glyph_subpaths.drain(..) { // Unwrapping here is ok because `self.vector_table` is initialized with a single `Table` item self.vector_table.element_mut(0).unwrap().append_subpath(subpath, false); } + if let Some(rect) = glyph_bbox_rectangle { + self.merged_click_target_subpaths.push(rect); + } } } @@ -120,10 +133,24 @@ impl PathBuilder { if self.vector_table.is_empty() { self.vector_table = Table::new_from_element(Vector::default()); } + + // With "Separate Glyph Elements" 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)); + } + self.vector_table } } +fn subpaths_bounding_box(subpaths: &[Subpath]) -> Option<[DVec2; 2]> { + subpaths + .iter() + .filter_map(|subpath| subpath.bounding_box()) + .reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]) +} + impl OutlinePen for PathBuilder { fn move_to(&mut self, x: f32, y: f32) { if !self.current_subpath.is_empty() { diff --git a/node-graph/nodes/vector/src/vector_modification_nodes.rs b/node-graph/nodes/vector/src/vector_modification_nodes.rs index 0ff9e358..2fcfae22 100644 --- a/node-graph/nodes/vector/src/vector_modification_nodes.rs +++ b/node-graph/nodes/vector/src/vector_modification_nodes.rs @@ -1,6 +1,6 @@ use core_types::table::Table; use core_types::uuid::NodeId; -use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_TRANSFORM, Ctx}; +use core_types::{ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_TRANSFORM, Ctx}; use glam::DAffine2; use graphic_types::Vector; use vector_types::vector::VectorModification; @@ -15,6 +15,9 @@ async fn path_modify(_ctx: impl Ctx, mut vector: Table, modification: Bo } modification.apply(vector.element_mut(0).expect("push should give one item")); + // Drop stale click-target override so hit testing uses the geometry the user is now editing + vector.remove_attribute(ATTR_EDITOR_CLICK_TARGET); + // Set the path to the encapsulating subgraph (drop our own trailing entry from `node_path`), // matching the `path_of_subgraph` proto so editor tools can route data back to the parent layer. let subgraph_path: Table = {