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
This commit is contained in:
Keavon Chambers
2026-05-02 18:12:42 -07:00
committed by GitHub
parent ab7f59ca61
commit 325e9aff06
11 changed files with 245 additions and 89 deletions

View File

@@ -215,6 +215,9 @@ pub enum DocumentMessage {
UpdateClickTargets {
click_targets: HashMap<NodeId, Vec<Arc<ClickTarget>>>,
},
UpdateOutlines {
outlines: HashMap<NodeId, Vec<Arc<ClickTarget>>>,
},
UpdateClipTargets {
clip_targets: HashSet<NodeId>,
},

View File

@@ -1270,6 +1270,19 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> 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);
}

View File

@@ -28,6 +28,9 @@ pub struct DocumentMetadata {
pub first_element_source_ids: HashMap<NodeId, Option<NodeId>>,
pub structure: HashMap<LayerNodeIdentifier, NodeRelations>,
pub click_targets: HashMap<LayerNodeIdentifier, Vec<Arc<ClickTarget>>>,
/// 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>>>,
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.
@@ -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<ClickTarget>]> {
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<Item = &subpath::Subpath<PointId>> {
static EMPTY: Vec<Arc<ClickTarget>> = 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<Item = &ClickTargetType> {
static EMPTY: Vec<Arc<ClickTarget>> = 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 {

View File

@@ -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<LayerNodeIdentifier, Vec<Arc<ClickTarget>>>) {
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<NodeId>) {
self.document_metadata.clip_targets = new_clip_targets;

View File

@@ -1823,7 +1823,10 @@ fn drag_shallowest_manipulation(responses: &mut VecDeque<Message>, 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<Message>, selected: Vec<La
);
if !remove {
tool_data.layers_dragging.extend(vec![layer]);
// 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(&layer) {
tool_data.layers_dragging.push(layer);
}
} else {
tool_data.layers_dragging.retain(|&selected_layer| layer != selected_layer);
}

View File

@@ -309,6 +309,7 @@ impl NodeGraphExecutor {
Err(e) => {
// 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);