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

@@ -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<Subpath<PointId>>,
pub vector_table: Table<Vector>,
/// Per-glyph bbox rectangles collected in single-row mode, published as `ATTR_EDITOR_CLICK_TARGET` in `finalize()`.
merged_click_target_subpaths: Vec<Subpath<PointId>>,
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<Vector>` 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<PointId>]) -> 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() {

View File

@@ -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<Vector>, 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<NodeId> = {