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:
parent
ab7f59ca61
commit
325e9aff06
|
|
@ -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>,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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::<T>() } 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<T> Table<T> {
|
|||
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<U: Clone + Send + Sync + Default + Debug + PartialEq + CacheHash + 'static, R, F: FnOnce(&mut U) -> R>(&mut self, key: &str, index: usize, f: F) -> R {
|
||||
|
|
|
|||
|
|
@ -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<NodeId, DAffine2>,
|
||||
pub first_element_source_id: HashMap<NodeId, Option<NodeId>>,
|
||||
pub click_targets: HashMap<NodeId, 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<NodeId, Vec<Arc<ClickTarget>>>,
|
||||
pub clip_targets: HashSet<NodeId>,
|
||||
pub vector_data: HashMap<NodeId, Arc<Vector>>,
|
||||
pub backgrounds: Vec<Background>,
|
||||
|
|
@ -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<ClickTarget>) {}
|
||||
|
||||
/// Like `add_upstream_click_targets` but for visual outlines. `Table<Vector>` overrides this to ignore `editor:click_target` so outlines reflect the actual geometry.
|
||||
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
|
||||
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<NodeId>)>, current_path: Option<NodeId>) {}
|
||||
|
||||
|
|
@ -490,6 +500,17 @@ impl Render for Graphic {
|
|||
}
|
||||
}
|
||||
|
||||
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
|
||||
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<Artboard>`.
|
||||
/// Reads the artboard metadata for the item at `index` from a `Table<Artboard>`.
|
||||
fn read_artboard_attributes(table: &Table<Artboard>, 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<Graphic> {
|
|||
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<Graphic> {
|
|||
|
||||
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
|
||||
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<NodeId> = 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<Graphic> {
|
|||
|
||||
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<ClickTarget>) {
|
||||
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<ClickTarget>) {
|
||||
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<Vector> {
|
|||
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<Vector> {
|
|||
.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<Vector> {
|
|||
}
|
||||
|
||||
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<Vector> {
|
|||
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<Vector> {
|
|||
|
||||
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<Vector> {
|
|||
}
|
||||
|
||||
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
|
||||
// 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<NodeId, Vec<Arc<ClickTarget>>> = HashMap::new();
|
||||
let mut accumulated_outlines: HashMap<NodeId, Vec<Arc<ClickTarget>>> = 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<NodeId> = 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<Vector> {
|
|||
// 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::<Vector>(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::<Vec<_>>();
|
||||
// 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<Vector> {
|
|||
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<ClickTarget>) {
|
||||
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::<Vector>(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<ClickTarget>) {
|
||||
// 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<Vector> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<ClickTarget>, 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<Raster<CPU>> {
|
||||
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
|
||||
for index in 0..self.len() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue