Add the Layer > Expand Fill/Stroke menu action (#4151)

* Add the Layer > Expand Fill/Stroke menu action

* Code review feedback
This commit is contained in:
Keavon Chambers 2026-05-15 13:10:50 -07:00 committed by GitHub
parent a56746c6bf
commit 16c7544d96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 112 additions and 5 deletions

View File

@ -1,2 +1,2 @@
https://github.com/Keavon/graphite-branded-assets/archive/69b92281fa4640c8df994c5bf441ba78ae43c6c9.tar.gz https://github.com/Keavon/graphite-branded-assets/archive/0d004aa61e6b48d316e8e5db6d59ccc4788f192d.tar.gz
3b827e8731a1214f1eca0240b9c2622697dc9e9ebfddc3a640ff88fb463161af 772d64518be43c99977ba56f69e574531c56e83d2df2f42ab066f77f74b0dd1f

View File

@ -514,6 +514,11 @@ impl LayoutHolder for MenuBarMessageHandler {
.icon("NodeShape") .icon("NodeShape")
.on_commit(|_| NodeGraphMessage::AddPathNode.into()) .on_commit(|_| NodeGraphMessage::AddPathNode.into())
.disabled(!make_path_editable_is_allowed), .disabled(!make_path_editable_is_allowed),
MenuListEntry::new("Expand Fill/Stroke")
.label("Expand Fill/Stroke")
.icon("ExpandFillStroke")
.on_commit(|_| DocumentMessage::ExpandFillStrokeOnSelectedLayers.into())
.disabled(no_active_document || !has_selected_layers),
], ],
]) ])
.widget_instance(), .widget_instance(),

View File

@ -86,6 +86,8 @@ pub enum DocumentMessage {
}, },
BlendSelectedLayers, BlendSelectedLayers,
MorphSelectedLayers, MorphSelectedLayers,
ExpandFillStrokeOnSelectedLayers,
ExpandFillStrokeOnSelectedLayersNoTransaction,
GroupSelectedLayers { GroupSelectedLayers {
group_folder_type: GroupFolderType, group_folder_type: GroupFolderType,
}, },

View File

@ -11,7 +11,7 @@ use crate::consts::{
use crate::messages::input_mapper::utility_types::macros::action_shortcut; use crate::messages::input_mapper::utility_types::macros::action_shortcut;
use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::data_panel::{DataPanelMessageContext, DataPanelMessageHandler}; use crate::messages::portfolio::document::data_panel::{DataPanelMessageContext, DataPanelMessageHandler};
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::graph_operation::utility_types::{ModifyInputsContext, TransformIn};
use crate::messages::portfolio::document::node_graph::NodeGraphMessageContext; use crate::messages::portfolio::document::node_graph::NodeGraphMessageContext;
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
use crate::messages::portfolio::document::node_graph::utility_types::FrontendGraphDataType; use crate::messages::portfolio::document::node_graph::utility_types::FrontendGraphDataType;
@ -20,7 +20,7 @@ use crate::messages::portfolio::document::overlays::utility_types::{OverlaysType
use crate::messages::portfolio::document::properties_panel::properties_panel_message_handler::PropertiesPanelMessageContext; use crate::messages::portfolio::document::properties_panel::properties_panel_message_handler::PropertiesPanelMessageContext;
use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier};
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, PTZ}; use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, PTZ};
use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeTemplate}; use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeTemplate, OutputConnector};
use crate::messages::portfolio::utility_types::{CachedData, PanelType}; use crate::messages::portfolio::utility_types::{CachedData, PanelType};
use crate::messages::prelude::*; use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_blend_mode, get_fill, get_opacity}; use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_blend_mode, get_fill, get_opacity};
@ -40,7 +40,7 @@ use graphene_std::subpath::Subpath;
use graphene_std::vector::PointId; use graphene_std::vector::PointId;
use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType};
use graphene_std::vector::misc::dvec2_to_point; use graphene_std::vector::misc::dvec2_to_point;
use graphene_std::vector::style::RenderMode; use graphene_std::vector::style::{Fill, RenderMode};
use kurbo::{Affine, BezPath, Line, PathSeg}; use kurbo::{Affine, BezPath, Line, PathSeg};
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
@ -631,6 +631,24 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
DocumentMessage::MorphSelectedLayers => { DocumentMessage::MorphSelectedLayers => {
self.handle_group_selected_layers(GroupFolderType::Morph, responses); self.handle_group_selected_layers(GroupFolderType::Morph, responses);
} }
DocumentMessage::ExpandFillStrokeOnSelectedLayers => {
// Snapshot must be taken before the mutations, so the actual work runs as a separate message
// queued after AddTransaction (which prepends StartTransaction/CommitTransaction to the queue).
// All mutations currently target the root document network, so guard against being invoked from inside a nested network.
if !self.selection_network_path.is_empty() {
log::error!("Expanding fill/stroke is only supported for the document network");
return;
}
if self.network_interface.selected_nodes().selected_layers(self.metadata()).next().is_none() {
return;
}
responses.add(DocumentMessage::AddTransaction);
responses.add(DocumentMessage::ExpandFillStrokeOnSelectedLayersNoTransaction);
}
DocumentMessage::ExpandFillStrokeOnSelectedLayersNoTransaction => {
// Mutates the network directly, so it must be queued to run after `AddTransaction` has snapshotted the document
self.handle_expand_fill_stroke_on_selected_layers(responses);
}
DocumentMessage::GroupSelectedLayers { group_folder_type } => { DocumentMessage::GroupSelectedLayers { group_folder_type } => {
self.handle_group_selected_layers(group_folder_type, responses); self.handle_group_selected_layers(group_folder_type, responses);
} }
@ -2350,6 +2368,86 @@ impl DocumentMessageHandler {
} }
} }
/// For each selected layer, splits its fill and stroke into two stacked layers connected
/// to a shared `Solidify Stroke` node via two `Index Elements` nodes (indices 0 and 1).
/// Layers with only a stroke get just a `Solidify Stroke` added.
/// Layers with only a fill, or neither, are left untouched.
fn handle_expand_fill_stroke_on_selected_layers(&mut self, responses: &mut VecDeque<Message>) {
let selected_layers: Vec<LayerNodeIdentifier> = self.network_interface.selected_nodes().selected_layers(self.metadata()).collect();
if selected_layers.is_empty() {
return;
}
let solidify_stroke_definition = document_node_definitions::resolve_proto_node_type(graphene_std::vector::solidify_stroke::IDENTIFIER).expect("Solidify Stroke node should exist");
let index_elements_definition = document_node_definitions::resolve_proto_node_type(graphene_std::graphic::index_elements::IDENTIFIER).expect("Index Elements node should exist");
let mut resulting_layers: Vec<NodeId> = Vec::new();
for layer in selected_layers {
let style = self.network_interface.document_metadata().layer_vector_data.get(&layer).map(|arc| arc.style.clone());
let Some(style) = style else {
resulting_layers.push(layer.to_node());
continue;
};
let has_fill = !matches!(style.fill, Fill::None);
// `style.stroke` is `Some` whenever a `Stroke` node is in the chain, even with weight 0 or a transparent color.
// So `is_some()` would treat invisibly-stroked fill-only layers as having a stroke.
let has_stroke = style.stroke.as_ref().is_some_and(|s| s.has_renderable_stroke());
// No stroke means there's nothing to solidify. Fill-only layers are already in the desired form, so skip.
if !has_stroke {
resulting_layers.push(layer.to_node());
continue;
}
let solidify_id = NodeId::new();
self.network_interface.insert_node(solidify_id, solidify_stroke_definition.default_node_template(), &[]);
self.network_interface.move_node_to_chain_start(&solidify_id, layer, &[], false);
if has_fill && has_stroke {
let (existing_index, new_index) = (0_f64, 1_f64);
let existing_index_template = index_elements_definition.node_template_input_override([None, Some(NodeInput::value(TaggedValue::F64(existing_index), false))]);
let existing_index_id = NodeId::new();
self.network_interface.insert_node(existing_index_id, existing_index_template, &[]);
self.network_interface.move_node_to_chain_start(&existing_index_id, layer, &[], false);
let parent = layer.parent(self.metadata()).unwrap_or(LayerNodeIdentifier::ROOT_PARENT);
let insert_index = parent.children(self.metadata()).position(|c| c == layer).unwrap_or(0);
let new_layer_id = NodeId::new();
let new_layer = ModifyInputsContext::new(&mut self.network_interface, responses).create_layer(new_layer_id);
self.network_interface.move_layer_to_stack(new_layer, parent, insert_index, &[]);
// Copy the original layer's stored name so the new layer shares it
let original_name = self
.network_interface
.node_metadata(&layer.to_node(), &[])
.map(|m| m.persistent_metadata.display_name.clone())
.unwrap_or_default();
if !original_name.is_empty() {
self.network_interface.set_display_name(&new_layer_id, original_name, &[]);
}
let new_index_template = index_elements_definition.node_template_input_override([None, Some(NodeInput::value(TaggedValue::F64(new_index), false))]);
let new_index_id = NodeId::new();
self.network_interface.insert_node(new_index_id, new_index_template, &[]);
self.network_interface.move_node_to_chain_start(&new_index_id, new_layer, &[], false);
self.network_interface.create_wire(&OutputConnector::node(solidify_id, 0), &InputConnector::node(new_index_id, 0), &[]);
resulting_layers.push(layer.to_node());
resulting_layers.push(new_layer.to_node());
} else {
resulting_layers.push(layer.to_node());
}
}
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: resulting_layers });
responses.add(NodeGraphMessage::RunDocumentGraph);
}
/// Helper method for MoveSelectedLayersTo message. /// Helper method for MoveSelectedLayersTo message.
/// Handles moving selected layers to a new parent with proper transform preservation. /// Handles moving selected layers to a new parent with proper transform preservation.
fn handle_move_selected_layers_to(&mut self, parent: LayerNodeIdentifier, insert_index: usize, responses: &mut VecDeque<Message>) { fn handle_move_selected_layers_to(&mut self, parent: LayerNodeIdentifier, insert_index: usize, responses: &mut VecDeque<Message>) {

View File

@ -129,6 +129,7 @@ import Cut from "/../branding/assets/icon-16px-solid/cut.svg";
import DeselectAll from "/../branding/assets/icon-16px-solid/deselect-all.svg"; import DeselectAll from "/../branding/assets/icon-16px-solid/deselect-all.svg";
import Edit from "/../branding/assets/icon-16px-solid/edit.svg"; import Edit from "/../branding/assets/icon-16px-solid/edit.svg";
import Empty from "/../branding/assets/icon-16px-solid/empty.svg"; import Empty from "/../branding/assets/icon-16px-solid/empty.svg";
import ExpandFillStroke from "/../branding/assets/icon-16px-solid/expand-fill-stroke.svg";
import EyeHidden from "/../branding/assets/icon-16px-solid/eye-hidden.svg"; import EyeHidden from "/../branding/assets/icon-16px-solid/eye-hidden.svg";
import EyeHide from "/../branding/assets/icon-16px-solid/eye-hide.svg"; import EyeHide from "/../branding/assets/icon-16px-solid/eye-hide.svg";
import EyeShow from "/../branding/assets/icon-16px-solid/eye-show.svg"; import EyeShow from "/../branding/assets/icon-16px-solid/eye-show.svg";
@ -268,6 +269,7 @@ const SOLID_16PX = {
DeselectAll: { svg: DeselectAll, size: 16 }, DeselectAll: { svg: DeselectAll, size: 16 },
Edit: { svg: Edit, size: 16 }, Edit: { svg: Edit, size: 16 },
Empty: { svg: Empty, size: 16 }, Empty: { svg: Empty, size: 16 },
ExpandFillStroke: { svg: ExpandFillStroke, size: 16 },
Eyedropper: { svg: Eyedropper, size: 16 }, Eyedropper: { svg: Eyedropper, size: 16 },
EyeHidden: { svg: EyeHidden, size: 16 }, EyeHidden: { svg: EyeHidden, size: 16 },
EyeHide: { svg: EyeHide, size: 16 }, EyeHide: { svg: EyeHide, size: 16 },