Skip to content

Commit 325e9af

Browse files
authored
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
1 parent ab7f59c commit 325e9af

11 files changed

Lines changed: 246 additions & 90 deletions

File tree

editor/src/messages/portfolio/document/document_message.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@ pub enum DocumentMessage {
215215
UpdateClickTargets {
216216
click_targets: HashMap<NodeId, Vec<Arc<ClickTarget>>>,
217217
},
218+
UpdateOutlines {
219+
outlines: HashMap<NodeId, Vec<Arc<ClickTarget>>>,
220+
},
218221
UpdateClipTargets {
219222
clip_targets: HashSet<NodeId>,
220223
},

editor/src/messages/portfolio/document/document_message_handler.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,6 +1270,19 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
12701270
.collect();
12711271
self.network_interface.update_click_targets(layer_click_targets);
12721272
}
1273+
DocumentMessage::UpdateOutlines { outlines } => {
1274+
let layer_outlines = outlines
1275+
.into_iter()
1276+
.filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id))
1277+
.filter_map(|(node_id, outlines)| {
1278+
self.network_interface.is_layer(&node_id, &[]).then(|| {
1279+
let layer = LayerNodeIdentifier::new(node_id, &self.network_interface);
1280+
(layer, outlines)
1281+
})
1282+
})
1283+
.collect();
1284+
self.network_interface.update_outlines(layer_outlines);
1285+
}
12731286
DocumentMessage::UpdateClipTargets { clip_targets } => {
12741287
self.network_interface.update_clip_targets(clip_targets);
12751288
}

editor/src/messages/portfolio/document/utility_types/document_metadata.rs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ pub struct DocumentMetadata {
2828
pub first_element_source_ids: HashMap<NodeId, Option<NodeId>>,
2929
pub structure: HashMap<LayerNodeIdentifier, NodeRelations>,
3030
pub click_targets: HashMap<LayerNodeIdentifier, Vec<Arc<ClickTarget>>>,
31+
/// Source-geometry outlines for hover/selection overlays, separate from `click_targets` so
32+
/// nodes with an `editor:click_target` override still outline the precise geometry.
33+
pub outlines: HashMap<LayerNodeIdentifier, Vec<Arc<ClickTarget>>>,
3134
pub clip_targets: HashSet<NodeId>,
3235
pub vector_modify: HashMap<NodeId, Vector>,
3336
/// Vector data keyed by layer ID, used as fallback when no Path node exists.
@@ -154,17 +157,23 @@ impl DocumentMetadata {
154157
// ===============================
155158

156159
impl DocumentMetadata {
160+
/// Outline targets if present, otherwise click targets. Used for bounding boxes and outline
161+
/// drawing so layers with an `editor:click_target` override report precise geometry bounds.
162+
fn visual_targets(&self, layer: LayerNodeIdentifier) -> Option<&[Arc<ClickTarget>]> {
163+
self.outlines.get(&layer).or_else(|| self.click_targets.get(&layer)).map(|v| v.as_slice())
164+
}
165+
157166
/// Get the bounding box of the click target of the specified layer in the specified transform space
158167
pub fn bounding_box_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2) -> Option<[DVec2; 2]> {
159-
self.click_targets(layer)?
168+
self.visual_targets(layer)?
160169
.iter()
161170
.filter_map(|click_target| click_target.bounding_box_with_transform(transform))
162171
.reduce(Quad::combine_bounds)
163172
}
164173

165174
/// Get the loose bounding box of the click target of the specified layer in the specified transform space
166175
pub fn loose_bounding_box_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2) -> Option<[DVec2; 2]> {
167-
self.click_targets(layer)?
176+
self.visual_targets(layer)?
168177
.iter()
169178
.filter_map(|click_target| match click_target.target_type() {
170179
ClickTargetType::Subpath(subpath) => subpath.loose_bounding_box_with_transform(transform),
@@ -210,18 +219,14 @@ impl DocumentMetadata {
210219
}
211220

212221
pub fn layer_outline(&self, layer: LayerNodeIdentifier) -> impl Iterator<Item = &subpath::Subpath<PointId>> {
213-
static EMPTY: Vec<Arc<ClickTarget>> = Vec::new();
214-
let click_targets = self.click_targets.get(&layer).unwrap_or(&EMPTY);
215-
click_targets.iter().filter_map(|target| match target.target_type() {
222+
self.visual_targets(layer).unwrap_or(&[]).iter().filter_map(|target| match target.target_type() {
216223
ClickTargetType::Subpath(subpath) => Some(subpath),
217224
_ => None,
218225
})
219226
}
220227

221228
pub fn layer_with_free_points_outline(&self, layer: LayerNodeIdentifier) -> impl Iterator<Item = &ClickTargetType> {
222-
static EMPTY: Vec<Arc<ClickTarget>> = Vec::new();
223-
let click_targets = self.click_targets.get(&layer).unwrap_or(&EMPTY);
224-
click_targets.iter().map(|target| target.target_type())
229+
self.visual_targets(layer).unwrap_or(&[]).iter().map(|target| target.target_type())
225230
}
226231

227232
pub fn is_clip(&self, node: NodeId) -> bool {

editor/src/messages/portfolio/document/utility_types/network_interface.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3276,6 +3276,7 @@ impl NodeNetworkInterface {
32763276
self.document_metadata.local_transforms.retain(|node, _| nodes.contains(node));
32773277
self.document_metadata.vector_modify.retain(|node, _| nodes.contains(node));
32783278
self.document_metadata.click_targets.retain(|layer, _| self.document_metadata.structure.contains_key(layer));
3279+
self.document_metadata.outlines.retain(|layer, _| self.document_metadata.structure.contains_key(layer));
32793280
}
32803281

32813282
/// Update the cached transforms of the layers
@@ -3294,6 +3295,11 @@ impl NodeNetworkInterface {
32943295
self.document_metadata.click_targets = new_click_targets;
32953296
}
32963297

3298+
/// Update the cached source-geometry outline targets of the layers
3299+
pub fn update_outlines(&mut self, new_outlines: HashMap<LayerNodeIdentifier, Vec<Arc<ClickTarget>>>) {
3300+
self.document_metadata.outlines = new_outlines;
3301+
}
3302+
32973303
/// Update the cached clip targets of the layers
32983304
pub fn update_clip_targets(&mut self, new_clip_targets: HashSet<NodeId>) {
32993305
self.document_metadata.clip_targets = new_clip_targets;

editor/src/messages/tool/tool_messages/select_tool.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1823,7 +1823,10 @@ fn drag_shallowest_manipulation(responses: &mut VecDeque<Message>, selected: Vec
18231823
}
18241824

18251825
let new_selected = final_selection.unwrap_or_else(|| clicked_layer.ancestors(document.metadata()).filter(not_artboard(document)).last().unwrap_or(clicked_layer));
1826-
tool_data.layers_dragging.extend(vec![new_selected]);
1826+
// Duplicates cause `SelectedNodesSet` to carry the layer twice, breaking the Data panel's single-selection check in `node_to_inspect`
1827+
if !tool_data.layers_dragging.contains(&new_selected) {
1828+
tool_data.layers_dragging.push(new_selected);
1829+
}
18271830
tool_data.layers_dragging.retain(|&selected_layer| !selected_layer.is_child_of(metadata, &new_selected));
18281831
if remove {
18291832
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
18851888
);
18861889

18871890
if !remove {
1888-
tool_data.layers_dragging.extend(vec![layer]);
1891+
// Duplicates cause `SelectedNodesSet` to carry the layer twice, breaking the Data panel's single-selection check in `node_to_inspect`
1892+
if !tool_data.layers_dragging.contains(&layer) {
1893+
tool_data.layers_dragging.push(layer);
1894+
}
18891895
} else {
18901896
tool_data.layers_dragging.retain(|&selected_layer| layer != selected_layer);
18911897
}

editor/src/node_graph_executor.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ impl NodeGraphExecutor {
309309
Err(e) => {
310310
// Clear the click targets while the graph is in an un-renderable state
311311
document.network_interface.update_click_targets(HashMap::new());
312+
document.network_interface.update_outlines(HashMap::new());
312313
document.network_interface.update_vector_modify(HashMap::new());
313314
return Err(format!("Node graph evaluation failed:\n{e}"));
314315
}
@@ -355,6 +356,7 @@ impl NodeGraphExecutor {
355356
// Clear the click targets while the graph is in an un-renderable state
356357

357358
document.network_interface.update_click_targets(HashMap::new());
359+
document.network_interface.update_outlines(HashMap::new());
358360
document.network_interface.update_vector_modify(HashMap::new());
359361

360362
log::trace!("{e}");
@@ -415,6 +417,7 @@ impl NodeGraphExecutor {
415417
local_transforms,
416418
first_element_source_id,
417419
click_targets,
420+
outlines,
418421
clip_targets,
419422
vector_data,
420423
backgrounds: _,
@@ -427,6 +430,7 @@ impl NodeGraphExecutor {
427430
first_element_source_id,
428431
});
429432
responses.add(DocumentMessage::UpdateClickTargets { click_targets });
433+
responses.add(DocumentMessage::UpdateOutlines { outlines });
430434
responses.add(DocumentMessage::UpdateClipTargets { clip_targets });
431435
responses.add(DocumentMessage::UpdateVectorData { vector_data });
432436
responses.add(DocumentMessage::RenderScrollbars);

node-graph/libraries/core-types/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ use std::any::TypeId;
3434
use std::future::Future;
3535
use std::pin::Pin;
3636
pub use table::{
37-
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,
38-
ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_START, ATTR_TRANSFORM, ATTR_TYPE,
37+
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,
38+
ATTR_LOCATION, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_START, ATTR_TRANSFORM, ATTR_TYPE,
3939
};
4040
#[cfg(feature = "wasm")]
4141
pub use tsify;

node-graph/libraries/core-types/src/table.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ pub const ATTR_EDITOR_LAYER_PATH: &str = "editor:layer_path";
3636
/// the original child layers after their content has been collapsed.
3737
pub const ATTR_EDITOR_MERGED_LAYERS: &str = "editor:merged_layers";
3838

39+
/// Optional `Vector` that overrides the row's own geometry for click-target generation.
40+
/// Used by the 'Text' node for per-glyph bounding-box rectangles so glyphs are selectable
41+
/// by clicking anywhere within their bounds, not just the filled letterform.
42+
pub const ATTR_EDITOR_CLICK_TARGET: &str = "editor:click_target";
43+
3944
/// Byte offset where a regex match begins ('Regex Find All', 'Regex Capture' text nodes).
4045
pub const ATTR_START: &str = "start";
4146

@@ -484,6 +489,13 @@ impl AttributeColumns {
484489
self.columns.iter().find_map(|(k, column)| if k == key { column.get_any(index)?.downcast_ref::<T>() } else { None })
485490
}
486491

492+
/// Removes the entire column for the given key, if present.
493+
fn remove_column(&mut self, key: &str) {
494+
if let Some(position) = self.columns.iter().position(|(k, _)| k == key) {
495+
self.columns.remove(position);
496+
}
497+
}
498+
487499
/// Finds or creates a column for the given key and type, returning its position.
488500
/// 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.
489501
/// A newly created column is filled with `T::default()` for all existing rows.
@@ -748,6 +760,11 @@ impl<T> Table<T> {
748760
self.attributes.set_value(key, index, value);
749761
}
750762

763+
/// Removes the entire attribute column for the given key, if present.
764+
pub fn remove_attribute(&mut self, key: &str) {
765+
self.attributes.remove_column(key);
766+
}
767+
751768
/// Runs the given closure on a mutable reference to the attribute value at the given row index,
752769
/// creating the column with defaults if it doesn't exist, and returns the closure's result.
753770
pub fn with_attribute_mut_or_default<U: Clone + Send + Sync + Default + Debug + PartialEq + CacheHash + 'static, R, F: FnOnce(&mut U) -> R>(&mut self, key: &str, index: usize, f: F) -> R {

0 commit comments

Comments
 (0)