Repository: GraphiteEditor/Graphite (language: rust)

Issue:
Add node button in the Properties panel
A button to 'add a node' should be added to the bottom of the properties panel. Upon clicking, the list of nodes will appear.


Discussion / hints:

Fix patch:
diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs
index 0ed17ea8a6..ba97834791 100644
--- a/editor/src/messages/frontend/frontend_message.rs
+++ b/editor/src/messages/frontend/frontend_message.rs
@@ -253,11 +253,6 @@ pub enum FrontendMessage {
 		#[serde(rename = "openDocuments")]
 		open_documents: Vec<FrontendDocumentDetails>,
 	},
-	UpdatePropertyPanelOptionsLayout {
-		#[serde(rename = "layoutTarget")]
-		layout_target: LayoutTarget,
-		diff: Vec<WidgetDiff>,
-	},
 	UpdatePropertyPanelSectionsLayout {
 		#[serde(rename = "layoutTarget")]
 		layout_target: LayoutTarget,
diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs
index 8bedf034c9..36c1158c13 100644
--- a/editor/src/messages/layout/layout_message_handler.rs
+++ b/editor/src/messages/layout/layout_message_handler.rs
@@ -214,6 +214,17 @@ impl LayoutMessageHandler {
 
 				responses.add(callback_message);
 			}
+			Widget::NodeCatalog(node_type_input) => match action {
+				WidgetValueAction::Commit => {
+					let callback_message = (node_type_input.on_commit.callback)(&());
+					responses.add(callback_message);
+				}
+				WidgetValueAction::Update => {
+					let value = value.as_str().expect("NodeCatalog update was not of type String").to_string();
+					let callback_message = (node_type_input.on_update.callback)(&value);
+					responses.add(callback_message);
+				}
+			},
 			Widget::NumberInput(number_input) => match action {
 				WidgetValueAction::Commit => {
 					let callback_message = (number_input.on_commit.callback)(&());
@@ -394,7 +405,6 @@ impl LayoutMessageHandler {
 			LayoutTarget::LayersPanelOptions => FrontendMessage::UpdateLayersPanelOptionsLayout { layout_target, diff },
 			LayoutTarget::MenuBar => unreachable!("Menu bar is not diffed"),
 			LayoutTarget::NodeGraphBar => FrontendMessage::UpdateNodeGraphBarLayout { layout_target, diff },
-			LayoutTarget::PropertiesOptions => FrontendMessage::UpdatePropertyPanelOptionsLayout { layout_target, diff },
 			LayoutTarget::PropertiesSections => FrontendMessage::UpdatePropertyPanelSectionsLayout { layout_target, diff },
 			LayoutTarget::ToolOptions => FrontendMessage::UpdateToolOptionsLayout { layout_target, diff },
 			LayoutTarget::ToolShelf => FrontendMessage::UpdateToolShelfLayout { layout_target, diff },
diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs
index b408eb39ad..06ba200795 100644
--- a/editor/src/messages/layout/utility_types/layout_widget.rs
+++ b/editor/src/messages/layout/utility_types/layout_widget.rs
@@ -38,8 +38,6 @@ pub enum LayoutTarget {
 	MenuBar,
 	/// Bar at the top of the node graph containing the location and the "Preview" and "Hide" buttons.
 	NodeGraphBar,
-	/// The bar at the top of the Properties panel containing the layer name and icon.
-	PropertiesOptions,
 	/// The body of the Properties panel containing many collapsable sections.
 	PropertiesSections,
 	/// The bar directly above the canvas, left-aligned and to the right of the document mode dropdown.
@@ -303,7 +301,7 @@ pub enum LayoutGroup {
 	},
 	// TODO: Move this from being a child of `enum LayoutGroup` to being a child of `enum Layout`
 	#[serde(rename = "section")]
-	Section { name: String, visible: bool, id: u64, layout: SubLayout },
+	Section { name: String, visible: bool, pinned: bool, id: u64, layout: SubLayout },
 }
 
 impl Default for LayoutGroup {
@@ -344,7 +342,7 @@ impl LayoutGroup {
 				Widget::TextInput(x) => &mut x.tooltip,
 				Widget::TextLabel(x) => &mut x.tooltip,
 				Widget::BreadcrumbTrailButtons(x) => &mut x.tooltip,
-				Widget::InvisibleStandinInput(_) | Widget::PivotInput(_) | Widget::RadioInput(_) | Widget::Separator(_) | Widget::WorkingColorsInput(_) => continue,
+				Widget::InvisibleStandinInput(_) | Widget::PivotInput(_) | Widget::RadioInput(_) | Widget::Separator(_) | Widget::WorkingColorsInput(_) | Widget::NodeCatalog(_) => continue,
 			};
 			if val.is_empty() {
 				val.clone_from(&tooltip);
@@ -385,22 +383,25 @@ impl LayoutGroup {
 				Self::Section {
 					name: current_name,
 					visible: current_visible,
+					pinned: current_pinned,
 					id: current_id,
 					layout: current_layout,
 				},
 				Self::Section {
 					name: new_name,
 					visible: new_visible,
+					pinned: new_pinned,
 					id: new_id,
 					layout: new_layout,
 				},
 			) => {
 				// Resend the entire panel if the lengths, names, visibility, or node IDs are different
 				// TODO: Diff insersion and deletion of items
-				if current_layout.len() != new_layout.len() || *current_name != new_name || *current_visible != new_visible || *current_id != new_id {
+				if current_layout.len() != new_layout.len() || *current_name != new_name || *current_visible != new_visible || *current_pinned != new_pinned || *current_id != new_id {
 					// Update self to reflect new changes
 					current_name.clone_from(&new_name);
 					*current_visible = new_visible;
+					*current_pinned = new_pinned;
 					*current_id = new_id;
 					current_layout.clone_from(&new_layout);
 
@@ -408,6 +409,7 @@ impl LayoutGroup {
 					let new_value = DiffUpdate::LayoutGroup(Self::Section {
 						name: new_name,
 						visible: new_visible,
+						pinned: new_pinned,
 						id: new_id,
 						layout: new_layout,
 					});
@@ -504,6 +506,7 @@ pub enum Widget {
 	IconLabel(IconLabel),
 	ImageLabel(ImageLabel),
 	InvisibleStandinInput(InvisibleStandinInput),
+	NodeCatalog(NodeCatalog),
 	NumberInput(NumberInput),
 	ParameterExposeButton(ParameterExposeButton),
 	PivotInput(PivotInput),
@@ -580,6 +583,7 @@ impl DiffUpdate {
 				| Widget::ImageLabel(_)
 				| Widget::CurveInput(_)
 				| Widget::InvisibleStandinInput(_)
+				| Widget::NodeCatalog(_)
 				| Widget::PivotInput(_)
 				| Widget::RadioInput(_)
 				| Widget::Separator(_)
diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs
index 3b10ab4a70..a7f0a843da 100644
--- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs
+++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs
@@ -274,6 +274,21 @@ pub enum NumberInputMode {
 	Range,
 }
 
+#[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, WidgetBuilder, specta::Type)]
+#[derivative(Debug, PartialEq, Default)]
+pub struct NodeCatalog {
+	pub disabled: bool,
+
+	// Callbacks
+	#[serde(skip)]
+	#[derivative(Debug = "ignore", PartialEq = "ignore")]
+	pub on_update: WidgetCallback<String>,
+
+	#[serde(skip)]
+	#[derivative(Debug = "ignore", PartialEq = "ignore")]
+	pub on_commit: WidgetCallback<()>,
+}
+
 #[derive(Clone, Default, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder, specta::Type)]
 #[derivative(Debug, PartialEq)]
 pub struct RadioInput {
diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs
index 760bcc1776..f11493374a 100644
--- a/editor/src/messages/portfolio/document/document_message.rs
+++ b/editor/src/messages/portfolio/document/document_message.rs
@@ -45,6 +45,9 @@ pub enum DocumentMessage {
 	},
 	CreateEmptyFolder,
 	DebugPrintDocument,
+	DeleteNode {
+		node_id: NodeId,
+	},
 	DeleteSelectedLayers,
 	DeselectAllLayers,
 	DocumentHistoryBackward,
@@ -128,6 +131,10 @@ pub enum DocumentMessage {
 	SetBlendModeForSelectedLayers {
 		blend_mode: BlendMode,
 	},
+	SetNodePinned {
+		node_id: NodeId,
+		pinned: bool,
+	},
 	SetOpacityForSelectedLayers {
 		opacity: f64,
 	},
@@ -142,6 +149,10 @@ pub enum DocumentMessage {
 		closure: Option<for<'a> fn(&'a mut SnappingState) -> &'a mut bool>,
 		snapping_state: bool,
 	},
+	SetToNodeOrLayer {
+		node_id: NodeId,
+		is_layer: bool,
+	},
 	SetViewMode {
 		view_mode: ViewMode,
 	},
diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs
index 3ca0c37cc7..ff33c28859 100644
--- a/editor/src/messages/portfolio/document/document_message_handler.rs
+++ b/editor/src/messages/portfolio/document/document_message_handler.rs
@@ -228,7 +228,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
 			DocumentMessage::PropertiesPanel(message) => {
 				let properties_panel_message_handler_data = PropertiesPanelMessageHandlerData {
 					network_interface: &self.network_interface,
-					selection_path: &self.selection_network_path,
+					selection_network_path: &self.selection_network_path,
 					document_name: self.name.as_str(),
 					executor,
 				};
@@ -359,6 +359,17 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
 			DocumentMessage::DebugPrintDocument => {
 				info!("{:#?}", self.network_interface);
 			}
+			DocumentMessage::DeleteNode { node_id } => {
+				responses.add(DocumentMessage::StartTransaction);
+
+				responses.add(NodeGraphMessage::DeleteNodes {
+					node_ids: vec![node_id],
+					delete_children: true,
+				});
+				responses.add(NodeGraphMessage::RunDocumentGraph);
+				responses.add(NodeGraphMessage::SelectedNodesUpdated);
+				responses.add(NodeGraphMessage::SendGraph);
+			}
 			DocumentMessage::DeleteSelectedLayers => {
 				responses.add(NodeGraphMessage::DeleteSelectedNodes { delete_children: true });
 			}
@@ -1008,6 +1019,13 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
 					responses.add(GraphOperationMessage::BlendModeSet { layer, blend_mode });
 				}
 			}
+			DocumentMessage::SetNodePinned { node_id, pinned } => {
+				responses.add(DocumentMessage::StartTransaction);
+				responses.add(NodeGraphMessage::SetPinned { node_id, pinned });
+				responses.add(NodeGraphMessage::RunDocumentGraph);
+				responses.add(NodeGraphMessage::SelectedNodesUpdated);
+				responses.add(NodeGraphMessage::SendGraph);
+			}
 			DocumentMessage::SetOpacityForSelectedLayers { opacity } => {
 				let opacity = opacity.clamp(0., 1.);
 				for layer in self.network_interface.selected_nodes(&[]).unwrap().selected_layers_except_artboards(&self.network_interface) {
@@ -1027,6 +1045,10 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
 					*closure(&mut self.snapping_state) = snapping_state;
 				}
 			}
+			DocumentMessage::SetToNodeOrLayer { node_id, is_layer } => {
+				responses.add(DocumentMessage::StartTransaction);
+				responses.add(NodeGraphMessage::SetToNodeOrLayer { node_id, is_layer });
+			}
 			DocumentMessage::SetViewMode { view_mode } => {
 				self.view_mode = view_mode;
 				responses.add_front(NodeGraphMessage::RunDocumentGraph);
diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs
index 669a892f0a..7ef1ee0238 100644
--- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs
+++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs
@@ -268,17 +268,20 @@ impl<'a> ModifyInputsContext<'a> {
 		}
 
 		// Create a new node if the node does not exist and update its inputs
-		existing_node_id.or_else(|| {
-			let output_layer = self.get_output_layer()?;
-			let Some(node_definition) = resolve_document_node_type(reference) else {
-				log::error!("Node type {} does not exist in ModifyInputsContext::existing_node_id", reference);
-				return None;
-			};
-			let node_id = NodeId::new();
-			self.network_interface.insert_node(node_id, node_definition.default_node_template(), &[]);
-			self.network_interface.move_node_to_chain_start(&node_id, output_layer, &[]);
-			Some(node_id)
-		})
+		existing_node_id.or_else(|| self.create_node(reference))
+	}
+
+	/// Create a new node inside the layer
+	pub fn create_node(&mut self, reference: &str) -> Option<NodeId> {
+		let output_layer = self.get_output_layer()?;
+		let Some(node_definition) = resolve_document_node_type(reference) else {
+			log::error!("Node type {} does not exist in ModifyInputsContext::existing_node_id", reference);
+			return None;
+		};
+		let node_id = NodeId::new();
+		self.network_interface.insert_node(node_id, node_definition.default_node_template(), &[]);
+		self.network_interface.move_node_to_chain_start(&node_id, output_layer, &[]);
+		Some(node_id)
 	}
 
 	pub fn fill_set(&mut self, fill: Fill) {
diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs
index c50a5095c8..f0adc8c2fe 100644
--- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs
+++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs
@@ -35,6 +35,7 @@ pub struct NodePropertiesContext<'a> {
 	pub executor: &'a mut NodeGraphExecutor,
 	pub network_interface: &'a NodeNetworkInterface,
 	pub selection_network_path: &'a [NodeId],
+	pub document_name: &'a str,
 }
 
 /// Acts as a description for a [DocumentNode] before it gets instantiated as one.
diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs
index 2f31a9f4f0..b2f12435dc 100644
--- a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs
+++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs
@@ -19,6 +19,14 @@ pub enum NodeGraphMessage {
 	Init,
 	SelectedNodesUpdated,
 	Copy,
+	CreateNodeInLayerNoTransaction {
+		node_type: String,
+		layer: LayerNodeIdentifier,
+	},
+	CreateNodeInLayerWithTransaction {
+		node_type: String,
+		layer: LayerNodeIdentifier,
+	},
 	CreateNodeFromContextMenu {
 		node_id: Option<NodeId>,
 		node_type: String,
diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs
index 85922b964b..a0a0c799f4 100644
--- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs
+++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs
@@ -2,6 +2,7 @@ use super::utility_types::{BoxSelection, ContextMenuInformation, DragStart, Fron
 use super::{document_node_definitions, node_properties};
 use crate::messages::input_mapper::utility_types::macros::action_keys;
 use crate::messages::layout::utility_types::widget_prelude::*;
+use crate::messages::portfolio::document::graph_operation::utility_types::ModifyInputsContext;
 use crate::messages::portfolio::document::node_graph::document_node_definitions::NodePropertiesContext;
 use crate::messages::portfolio::document::node_graph::utility_types::{ContextMenuData, Direction, FrontendGraphDataType};
 use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
@@ -13,7 +14,7 @@ use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
 use graph_craft::document::{DocumentNodeImplementation, NodeId, NodeInput};
 use graph_craft::proto::GraphErrors;
 use graphene_core::*;
-use renderer::{ClickTarget, Quad};
+use renderer::Quad;
 
 use glam::{DAffine2, DVec2, IVec2};
 use std::cmp::Ordering;
@@ -136,8 +137,20 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
 
 				responses.add(FrontendMessage::TriggerTextCopy { copy_text });
 			}
+			NodeGraphMessage::CreateNodeInLayerNoTransaction { node_type, layer } => {
+				let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) else {
+					return;
+				};
+				modify_inputs.create_node(&node_type);
+			}
+			NodeGraphMessage::CreateNodeInLayerWithTransaction { node_type, layer } => {
+				responses.add(DocumentMessage::AddTransaction);
+				responses.add(NodeGraphMessage::CreateNodeInLayerNoTransaction { node_type, layer });
+				responses.add(PropertiesPanelMessage::Refresh);
+				responses.add(NodeGraphMessage::RunDocumentGraph);
+			}
 			NodeGraphMessage::CreateNodeFromContextMenu { node_id, node_type, x, y } => {
-				let node_id = node_id.unwrap_or_else(|| NodeId::new());
+				let node_id = node_id.unwrap_or_else(NodeId::new);
 
 				let Some(document_node_type) = document_node_definitions::resolve_document_node_type(&node_type) else {
 					responses.add(DialogMessage::DisplayDialogError {
@@ -429,31 +442,6 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
 					.map(|selected_nodes| selected_nodes.selected_nodes().cloned().collect())
 					.unwrap_or_default();
 
-				// If the user is clicking on the create nodes list or context menu, break here
-				if let Some(context_menu) = &self.context_menu {
-					let context_menu_viewport = network_metadata
-						.persistent_metadata
-						.navigation_metadata
-						.node_graph_to_viewport
-						.transform_point2(DVec2::new(context_menu.context_menu_coordinates.0 as f64, context_menu.context_menu_coordinates.1 as f64));
-					let (width, height) = if matches!(context_menu.context_menu_data, ContextMenuData::ToggleLayer { .. }) {
-						// Height and width for toggle layer menu
-						(173., 34.)
-					} else {
-						// Height and width for create node menu
-						(180., 200.)
-					};
-					let context_menu_subpath = bezier_rs::Subpath::new_rounded_rect(
-						DVec2::new(context_menu_viewport.x, context_menu_viewport.y),
-						DVec2::new(context_menu_viewport.x + width, context_menu_viewport.y + height),
-						[5.; 4],
-					);
-					let context_menu_click_target = ClickTarget::new(context_menu_subpath, 0.);
-					if context_menu_click_target.intersect_point(click, DAffine2::IDENTITY) {
-						return;
-					}
-				}
-
 				// Since the user is clicking elsewhere in the graph, ensure the add nodes list is closed
 				if self.context_menu.is_some() {
 					self.context_menu = None;
@@ -1585,14 +1573,26 @@ impl NodeGraphMessageHandler {
 			0 => {
 				let selected_nodes = nodes
 					.iter()
-					.filter_map(|node_id| network.nodes.get(node_id).map(|node| node_properties::generate_node_properties(node, *node_id, context)))
+					.filter_map(|node_id| network.nodes.get(node_id).map(|node| node_properties::generate_node_properties(node, *node_id, false, context)))
 					.collect::<Vec<_>>();
 				if !selected_nodes.is_empty() {
 					return selected_nodes;
 				}
 
+				let mut properties = vec![LayoutGroup::Row {
+					widgets: vec![
+						Separator::new(SeparatorType::Related).widget_holder(),
+						IconLabel::new("File").tooltip("Name of the current document").widget_holder(),
+						Separator::new(SeparatorType::Related).widget_holder(),
+						TextInput::new(context.document_name)
+							.on_update(|text_input| DocumentMessage::RenameDocument { new_name: text_input.value.clone() }.into())
+							.widget_holder(),
+						Separator::new(SeparatorType::Related).widget_holder(),
+					],
+				}];
+
 				// And if no nodes are selected, show properties for all pinned nodes
-				network
+				let pinned_node_properties = network
 					.nodes
 					.iter()
 					.filter_map(|(node_id, node)| {
@@ -1604,28 +1604,68 @@ impl NodeGraphMessageHandler {
 						};
 
 						if pinned {
-							Some(node_properties::generate_node_properties(node, *node_id, context))
+							Some(node_properties::generate_node_properties(node, *node_id, true, context))
 						} else {
 							None
 						}
 					})
-					.collect::<Vec<_>>()
+					.collect::<Vec<_>>();
+
+				properties.extend(pinned_node_properties);
+				properties
 			}
 			// If one layer is selected, filter out all selected nodes that are not upstream of it. If there are no nodes left, show properties for the layer. Otherwise, show nothing.
 			1 => {
+				let layer = layers[0];
 				let nodes_not_upstream_of_layer = nodes.into_iter().filter(|&selected_node_id| {
 					!context
 						.network_interface
-						.is_node_upstream_of_another_by_horizontal_flow(layers[0], context.selection_network_path, selected_node_id)
+						.is_node_upstream_of_another_by_horizontal_flow(layer, context.selection_network_path, selected_node_id)
 				});
 				if nodes_not_upstream_of_layer.count() > 0 {
 					return Vec::new();
 				}
 
+				let mut layer_properties = vec![LayoutGroup::Row {
+					widgets: vec![
+						Separator::new(SeparatorType::Related).widget_holder(),
+						IconLabel::new("Layer").tooltip("Name of the selected layer").widget_holder(),
+						Separator::new(SeparatorType::Related).widget_holder(),
+						TextInput::new(context.document_name)
+							.on_update(move |text_input| {
+								NodeGraphMessage::SetDisplayName {
+									node_id: layer,
+									alias: text_input.value.clone(),
+								}
+								.into()
+							})
+							.widget_holder(),
+						Separator::new(SeparatorType::Related).widget_holder(),
+						{
+							let node_chooser = NodeCatalog::new()
+								.on_update(move |node_type| {
+									NodeGraphMessage::CreateNodeInLayerWithTransaction {
+										node_type: node_type.clone(),
+										layer: LayerNodeIdentifier::new_unchecked(layer),
+									}
+									.into()
+								})
+								.widget_holder();
+							let popover_layout = vec![LayoutGroup::Row { widgets: vec![node_chooser] }];
+							PopoverButton::new()
+								.icon(Some("Node".to_string()))
+								.tooltip("Add an operation to the end of this layer's chain of nodes")
+								.popover_layout(popover_layout)
+								.widget_holder()
+						},
+						Separator::new(SeparatorType::Related).widget_holder(),
+					],
+				}];
+
 				// Iterate through all the upstream nodes, but stop when we reach another layer (since that's a point where we switch from horizontal to vertical flow)
-				context
+				let node_properties = context
 					.network_interface
-					.upstream_flow_back_from_nodes(vec![layers[0]], context.selection_network_path, network_interface::FlowType::HorizontalFlow)
+					.upstream_flow_back_from_nodes(vec![layer], context.selection_network_path, network_interface::FlowType::HorizontalFlow)
 					.enumerate()
 					.take_while(|(i, node_id)| {
 						if *i == 0 {
@@ -1635,8 +1675,11 @@ impl NodeGraphMessageHandler {
 						}
 					})
 					.filter_map(|(_, node_id)| network.nodes.get(&node_id).map(|node| (node, node_id)))
-					.map(|(node, node_id)| node_properties::generate_node_properties(node, node_id, context))
-					.collect()
+					.map(|(node, node_id)| node_properties::generate_node_properties(node, node_id, false, context))
+					.collect::<Vec<_>>();
+
+				layer_properties.extend(node_properties);
+				layer_properties
 			}
 			// If multiple layers and/or nodes are selected, show nothing
 			_ => Vec::new(),
diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs
index cc4ca14280..822d8997fb 100644
--- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs
+++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs
@@ -2253,7 +2253,7 @@ pub(crate) fn index_properties(document_node: &DocumentNode, node_id: NodeId, _c
 	vec![LayoutGroup::Row { widgets: index }]
 }
 
-pub(crate) fn generate_node_properties(document_node: &DocumentNode, node_id: NodeId, context: &mut NodePropertiesContext) -> LayoutGroup {
+pub(crate) fn generate_node_properties(document_node: &DocumentNode, node_id: NodeId, pinned: bool, context: &mut NodePropertiesContext) -> LayoutGroup {
 	let reference = context.network_interface.reference(&node_id, context.selection_network_path).clone();
 	let layout = if let Some(ref reference) = reference {
 		match super::document_node_definitions::resolve_document_node_type(reference) {
@@ -2267,6 +2267,7 @@ pub(crate) fn generate_node_properties(document_node: &DocumentNode, node_id: No
 	LayoutGroup::Section {
 		name: reference.unwrap_or_default(),
 		visible: document_node.visible,
+		pinned,
 		id: node_id.0,
 		layout,
 	}
diff --git a/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs b/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs
index 95c03e40df..17dee30702 100644
--- a/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs
+++ b/editor/src/messages/portfolio/document/properties_panel/properties_panel_message_handler.rs
@@ -11,17 +11,13 @@ impl<'a> MessageHandler<PropertiesPanelMessage, (&PersistentData, PropertiesPane
 	fn process_message(&mut self, message: PropertiesPanelMessage, responses: &mut VecDeque<Message>, (persistent_data, data): (&PersistentData, PropertiesPanelMessageHandlerData)) {
 		let PropertiesPanelMessageHandlerData {
 			network_interface,
-			selection_path,
+			selection_network_path,
 			document_name,
 			executor,
 		} = data;
 
 		match message {
 			PropertiesPanelMessage::Clear => {
-				responses.add(LayoutMessage::SendLayout {
-					layout: Layout::WidgetLayout(WidgetLayout::new(vec![])),
-					layout_target: LayoutTarget::PropertiesOptions,
-				});
 				responses.add(LayoutMessage::SendLayout {
 					layout: Layout::WidgetLayout(WidgetLayout::new(vec![])),
 					layout_target: LayoutTarget::PropertiesSections,
@@ -31,27 +27,13 @@ impl<'a> MessageHandler<PropertiesPanelMessage, (&PersistentData, PropertiesPane
 				let mut context = NodePropertiesContext {
 					persistent_data,
 					responses,
-					executor,
 					network_interface,
-					selection_network_path: selection_path,
+					selection_network_path,
+					document_name,
+					executor,
 				};
-
 				let properties_sections = NodeGraphMessageHandler::collate_properties(&mut context);
 
-				let options_bar = vec![LayoutGroup::Row {
-					widgets: vec![
-						IconLabel::new("File").tooltip("Document name").widget_holder(),
-						Separator::new(SeparatorType::Related).widget_holder(),
-						TextInput::new(document_name)
-							.on_update(|text_input| DocumentMessage::RenameDocument { new_name: text_input.value.clone() }.into())
-							.widget_holder(),
-					],
-				}];
-
-				context.responses.add(LayoutMessage::SendLayout {
-					layout: Layout::WidgetLayout(WidgetLayout::new(options_bar)),
-					layout_target: LayoutTarget::PropertiesOptions,
-				});
 				context.responses.add(LayoutMessage::SendLayout {
 					layout: Layout::WidgetLayout(WidgetLayout::new(properties_sections)),
 					layout_target: LayoutTarget::PropertiesSections,
diff --git a/editor/src/messages/portfolio/document/properties_panel/utility_types.rs b/editor/src/messages/portfolio/document/properties_panel/utility_types.rs
index 04edbba8b8..74dd563ddb 100644
--- a/editor/src/messages/portfolio/document/properties_panel/utility_types.rs
+++ b/editor/src/messages/portfolio/document/properties_panel/utility_types.rs
@@ -5,7 +5,7 @@ use crate::node_graph_executor::NodeGraphExecutor;
 
 pub struct PropertiesPanelMessageHandlerData<'a> {
 	pub network_interface: &'a NodeNetworkInterface,
-	pub selection_path: &'a [NodeId],
+	pub selection_network_path: &'a [NodeId],
 	pub document_name: &'a str,
 	pub executor: &'a mut NodeGraphExecutor,
 }
diff --git a/frontend/assets/icon-16px-solid/node.svg b/frontend/assets/icon-16px-solid/node.svg
new file mode 100644
index 0000000000..ddee1c22a7
--- /dev/null
+++ b/frontend/assets/icon-16px-solid/node.svg
@@ -0,0 +1,5 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+	<path d="M12,2h-2v1h2c1.1,0,2,.9,2,2v6c0,1.1-.9,2-2,2H4c-1.1,0-2-.9-2-2v-3h-1v3c0,1.65,1.35,3,3,3h8c1.65,0,3-1.35,3-3v-6c0-1.65-1.35-3-3-3Z" />
+	<polygon points="10.25 9.25 12.5 8.5 10.25 7.75 9.5 5.5 8.75 7.75 6.5 8.5 8.75 9.25 9.5 11.5 10.25 9.25" />
+	<polygon points="5.3 5.3 8.5 4.5 5.3 3.7 4.5 .5 3.7 3.7 .5 4.5 3.7 5.3 4.5 8.5 5.3 5.3" />
+</svg>
diff --git a/frontend/src/components/floating-menus/NodeCatalog.svelte b/frontend/src/components/floating-menus/NodeCatalog.svelte
new file mode 100644
index 0000000000..42749ec15d
--- /dev/null
+++ b/frontend/src/components/floating-menus/NodeCatalog.svelte
@@ -0,0 +1,161 @@
+<script lang="ts">
+	import { createEventDispatcher, getContext, onMount } from "svelte";
+
+	import type { NodeGraphState } from "@graphite/state-providers/node-graph";
+	import type { FrontendNodeType } from "@graphite/wasm-communication/messages";
+
+	import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte";
+	import TextInput from "@graphite/components/widgets/inputs/TextInput.svelte";
+	import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
+
+	const dispatch = createEventDispatcher<{ selectNodeType: string }>();
+	const nodeGraph = getContext<NodeGraphState>("nodeGraph");
+
+	export let disabled = false;
+
+	let nodeSearchInput: TextInput | undefined = undefined;
+	let searchTerm = "";
+
+	$: nodeCategories = buildNodeCategories($nodeGraph.nodeTypes, searchTerm);
+
+	type NodeCategoryDetails = {
+		nodes: FrontendNodeType[];
+		open: boolean;
+	};
+
+	function buildNodeCategories(nodeTypes: FrontendNodeType[], searchTerm: string): [string, NodeCategoryDetails][] {
+		const categories = new Map<string, NodeCategoryDetails>();
+
+		nodeTypes.forEach((node) => {
+			let nameIncludesSearchTerm = node.name.toLowerCase().includes(searchTerm.toLowerCase());
+
+			// Quick and dirty hack to alias "Layer" to "Merge" in the search
+			if (node.name === "Merge") {
+				nameIncludesSearchTerm = nameIncludesSearchTerm || "Layer".toLowerCase().includes(searchTerm.toLowerCase());
+			}
+
+			if (searchTerm.length > 0 && !nameIncludesSearchTerm && !node.category.toLowerCase().includes(searchTerm.toLowerCase())) {
+				return;
+			}
+
+			const category = categories.get(node.category);
+			let open = nameIncludesSearchTerm;
+			if (searchTerm.length === 0) {
+				open = false;
+			}
+
+			if (category) {
+				category.open = open;
+				category.nodes.push(node);
+			} else
+				categories.set(node.category, {
+					open,
+					nodes: [node],
+				});
+		});
+
+		const START_CATEGORIES_ORDER = ["UNCATEGORIZED", "General", "Value", "Math", "Style"];
+		const END_CATEGORIES_ORDER = ["Debug"];
+		return Array.from(categories)
+			.sort((a, b) => a[0].localeCompare(b[0]))
+			.sort((a, b) => {
+				const aIndex = START_CATEGORIES_ORDER.findIndex((x) => a[0].startsWith(x));
+				const bIndex = START_CATEGORIES_ORDER.findIndex((x) => b[0].startsWith(x));
+				if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
+				if (aIndex !== -1) return -1;
+				if (bIndex !== -1) return 1;
+				return 0;
+			})
+			.sort((a, b) => {
+				const aIndex = END_CATEGORIES_ORDER.findIndex((x) => a[0].startsWith(x));
+				const bIndex = END_CATEGORIES_ORDER.findIndex((x) => b[0].startsWith(x));
+				if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
+				if (aIndex !== -1) return 1;
+				if (bIndex !== -1) return -1;
+				return 0;
+			});
+	}
+
+	onMount(() => {
+		setTimeout(() => nodeSearchInput?.focus(), 0);
+	});
+</script>
+
+<div class="node-catalog">
+	<TextInput placeholder="Search Nodes..." value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
+	<div class="list-results" on:wheel|passive|stopPropagation>
+		{#each nodeCategories as nodeCategory}
+			<details open={nodeCategory[1].open}>
+				<summary>
+					<TextLabel>{nodeCategory[0]}</TextLabel>
+				</summary>
+				{#each nodeCategory[1].nodes as nodeType}
+					<TextButton {disabled} label={nodeType.name} action={() => dispatch("selectNodeType", nodeType.name)} />
+				{/each}
+			</details>
+		{:else}
+			<TextLabel>No search results</TextLabel>
+		{/each}
+	</div>
+</div>
+
+<style lang="scss" global>
+	.node-catalog {
+		max-height: 40vh;
+		min-width: 250px;
+		display: flex;
+		flex-direction: column;
+		align-items: stretch;
+
+		.text-input {
+			flex: 0 0 auto;
+			margin-bottom: 4px;
+		}
+
+		.list-results {
+			overflow-y: auto;
+			flex: 1 1 auto;
+			// Together with the `margin-right: 4px;` on `details` below, this keeps a gap between the listings and the scrollbar
+			margin-right: -4px;
+
+			details {
+				cursor: pointer;
+				position: relative;
+				// Together with the `margin-right: -4px;` on `.list-results` above, this keeps a gap between the listings and the scrollbar
+				margin-right: 4px;
+
+				&[open] summary .text-label::before {
+					transform: rotate(90deg);
+				}
+
+				summary {
+					display: flex;
+					align-items: center;
+					gap: 2px;
+
+					.text-label {
+						padding-left: 16px;
+						position: relative;
+
+						&::before {
+							content: "";
+							position: absolute;
+							margin: auto;
+							top: 0;
+							bottom: 0;
+							left: 0;
+							width: 8px;
+							height: 8px;
+							background: var(--icon-expand-collapse-arrow);
+						}
+					}
+				}
+
+				.text-button {
+					width: 100%;
+					margin: 4px 0;
+				}
+			}
+		}
+	}
+</style>
diff --git a/frontend/src/components/panels/Properties.svelte b/frontend/src/components/panels/Properties.svelte
index 70baaa28e0..e8abc5e700 100644
--- a/frontend/src/components/panels/Properties.svelte
+++ b/frontend/src/components/panels/Properties.svelte
@@ -2,23 +2,16 @@
 	import { getContext, onMount } from "svelte";
 
 	import type { Editor } from "@graphite/wasm-communication/editor";
-	import { defaultWidgetLayout, patchWidgetLayout, UpdatePropertyPanelOptionsLayout, UpdatePropertyPanelSectionsLayout } from "@graphite/wasm-communication/messages";
+	import { defaultWidgetLayout, patchWidgetLayout, UpdatePropertyPanelSectionsLayout } from "@graphite/wasm-communication/messages";
 
 	import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
-	import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
 	import WidgetLayout from "@graphite/components/widgets/WidgetLayout.svelte";
 
 	const editor = getContext<Editor>("editor");
 
-	let propertiesOptionsLayout = defaultWidgetLayout();
 	let propertiesSectionsLayout = defaultWidgetLayout();
 
 	onMount(() => {
-		editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelOptionsLayout, (updatePropertyPanelOptionsLayout) => {
-			patchWidgetLayout(propertiesOptionsLayout, updatePropertyPanelOptionsLayout);
-			propertiesOptionsLayout = propertiesOptionsLayout;
-		});
-
 		editor.subscriptions.subscribeJsMessage(UpdatePropertyPanelSectionsLayout, (updatePropertyPanelSectionsLayout) => {
 			patchWidgetLayout(propertiesSectionsLayout, updatePropertyPanelSectionsLayout);
 			propertiesSectionsLayout = propertiesSectionsLayout;
@@ -27,9 +20,6 @@
 </script>
 
 <LayoutCol class="properties">
-	<LayoutRow class="options-bar">
-		<WidgetLayout layout={propertiesOptionsLayout} />
-	</LayoutRow>
 	<LayoutCol class="sections" scrollableY={true}>
 		<WidgetLayout layout={propertiesSectionsLayout} />
 	</LayoutCol>
diff --git a/frontend/src/components/views/Graph.svelte b/frontend/src/components/views/Graph.svelte
index e40c23b4e7..cfa979ee98 100644
--- a/frontend/src/components/views/Graph.svelte
+++ b/frontend/src/components/views/Graph.svelte
@@ -7,14 +7,13 @@
 	import type { IconName } from "@graphite/utility-functions/icons";
 	import type { Editor } from "@graphite/wasm-communication/editor";
 	import type { Node } from "@graphite/wasm-communication/messages";
-	import type { FrontendNodeWire, FrontendNodeType, FrontendNode, FrontendGraphInput, FrontendGraphOutput, FrontendGraphDataType, WirePath } from "@graphite/wasm-communication/messages";
+	import type { FrontendNodeWire, FrontendNode, FrontendGraphInput, FrontendGraphOutput, FrontendGraphDataType, WirePath } from "@graphite/wasm-communication/messages";
 
+	import NodeCatalog from "@graphite/components/floating-menus/NodeCatalog.svelte";
 	import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
 	import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
 	import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
-	import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte";
 	import RadioInput from "@graphite/components/widgets/inputs/RadioInput.svelte";
-	import TextInput from "@graphite/components/widgets/inputs/TextInput.svelte";
 	import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
 	import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
 	const GRID_COLLAPSE_SPACING = 10;
@@ -25,14 +24,12 @@
 
 	let graph: HTMLDivElement | undefined;
 	let nodesContainer: HTMLDivElement | undefined;
-	let nodeSearchInput: TextInput | undefined;
 
 	// TODO: Using this not-complete code, or another better approach, make it so the dragged in-progress connector correctly handles showing/hiding the SVG shape of the connector caps
 	// let wireInProgressFromLayerTop: bigint | undefined = undefined;
 	// let wireInProgressFromLayerBottom: bigint | undefined = undefined;
 
 	let nodeWirePaths: WirePath[] = [];
-	let searchTerm = "";
 
 	// TODO: Convert these arrays-of-arrays to a Map?
 	let inputs: SVGSVGElement[][] = [];
@@ -43,13 +40,6 @@
 
 	$: gridSpacing = calculateGridSpacing($nodeGraph.transform.scale);
 	$: dotRadius = 1 + Math.floor($nodeGraph.transform.scale - 0.5 + 0.001) / 2;
-	$: nodeCategories = buildNodeCategories($nodeGraph.nodeTypes, searchTerm);
-
-	$: (() => {
-		if ($nodeGraph.contextMenuInformation?.contextMenuData === "CreateNode") {
-			setTimeout(() => nodeSearchInput?.focus(), 0);
-		}
-	})();
 
 	$: wirePaths = createWirePaths($nodeGraph.wirePathInProgress, nodeWirePaths);
 
@@ -64,63 +54,6 @@
 		return sparse;
 	}
 
-	type NodeCategoryDetails = {
-		nodes: FrontendNodeType[];
-		open: boolean;
-	};
-
-	function buildNodeCategories(nodeTypes: FrontendNodeType[], searchTerm: string): [string, NodeCategoryDetails][] {
-		const categories = new Map<string, NodeCategoryDetails>();
-
-		nodeTypes.forEach((node) => {
-			let nameIncludesSearchTerm = node.name.toLowerCase().includes(searchTerm.toLowerCase());
-			// Quick and dirty hack to alias "Layer" to "Merge" in the search
-			if (node.name === "Merge") {
-				nameIncludesSearchTerm = nameIncludesSearchTerm || "Layer".toLowerCase().includes(searchTerm.toLowerCase());
-			}
-
-			if (searchTerm.length > 0 && !nameIncludesSearchTerm && !node.category.toLowerCase().includes(searchTerm.toLowerCase())) {
-				return;
-			}
-
-			const category = categories.get(node.category);
-			let open = nameIncludesSearchTerm;
-			if (searchTerm.length === 0) {
-				open = false;
-			}
-
-			if (category) {
-				category.open = open;
-				category.nodes.push(node);
-			} else
-				categories.set(node.category, {
-					open,
-					nodes: [node],
-				});
-		});
-
-		const START_CATEGORIES_ORDER = ["UNCATEGORIZED", "General", "Value", "Math", "Style"];
-		const END_CATEGORIES_ORDER = ["Debug"];
-		return Array.from(categories)
-			.sort((a, b) => a[0].localeCompare(b[0]))
-			.sort((a, b) => {
-				const aIndex = START_CATEGORIES_ORDER.findIndex((x) => a[0].startsWith(x));
-				const bIndex = START_CATEGORIES_ORDER.findIndex((x) => b[0].startsWith(x));
-				if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
-				if (aIndex !== -1) return -1;
-				if (bIndex !== -1) return 1;
-				return 0;
-			})
-			.sort((a, b) => {
-				const aIndex = END_CATEGORIES_ORDER.findIndex((x) => a[0].startsWith(x));
-				const bIndex = END_CATEGORIES_ORDER.findIndex((x) => b[0].startsWith(x));
-				if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
-				if (aIndex !== -1) return 1;
-				if (bIndex !== -1) return -1;
-				return 0;
-			});
-	}
-
 	function createWirePaths(wirePathInProgress: WirePath | undefined, nodeWirePaths: WirePath[]): WirePath[] {
 		const maybeWirePathInProgress = wirePathInProgress ? [wirePathInProgress] : [];
 		return [...maybeWirePathInProgress, ...nodeWirePaths];
@@ -391,7 +324,6 @@
 	{#if $nodeGraph.contextMenuInformation}
 		<LayoutCol
 			class="context-menu"
-			classes={{ "create-node-menu": $nodeGraph.contextMenuInformation.contextMenuData === "CreateNode" }}
 			data-context-menu
 			styles={{
 				left: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates.x * $nodeGraph.transform.scale + $nodeGraph.transform.x}px`,
@@ -399,21 +331,7 @@
 			}}
 		>
 			{#if $nodeGraph.contextMenuInformation.contextMenuData === "CreateNode"}
-				<TextInput placeholder="Search Nodes..." value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
-				<div class="list-results" on:wheel|passive|stopPropagation>
-					{#each nodeCategories as nodeCategory}
-						<details open={nodeCategory[1].open}>
-							<summary>
-								<TextLabel>{nodeCategory[0]}</TextLabel>
-							</summary>
-							{#each nodeCategory[1].nodes as nodeType}
-								<TextButton label={nodeType.name} action={() => createNode(nodeType.name)} />
-							{/each}
-						</details>
-					{:else}
-						<TextLabel>No search results</TextLabel>
-					{/each}
-				</div>
+				<NodeCatalog on:selectNodeType={(e) => createNode(e.detail)} />
 			{:else}
 				{@const contextMenuData = $nodeGraph.contextMenuInformation.contextMenuData}
 				<LayoutRow class="toggle-layer-or-node">
@@ -871,64 +789,6 @@
 			background-color: var(--color-3-darkgray);
 			border-radius: 4px;
 
-			&.create-node-menu {
-				height: 200px; // For some reason, when attemping to make this taller, the bottom few categories don't open when clicked, but instead immediately close the menu
-				width: 180px; // Also when making this wider, clicking the scrollbar on the right edge of the menu causes the menu to close immediately
-			}
-
-			.text-input {
-				flex: 0 0 auto;
-				margin-bottom: 4px;
-			}
-
-			.list-results {
-				overflow-y: auto;
-				flex: 1 1 auto;
-				// Together with the `margin-right: 4px;` on `details` below, this keeps a gap between the listings and the scrollbar
-				margin-right: -4px;
-
-				details {
-					cursor: pointer;
-					display: flex;
-					flex-direction: column;
-					// Together with the `margin-right: -4px;` on `.list-results` above, this keeps a gap between the listings and the scrollbar
-					margin-right: 4px;
-
-					&[open] summary .text-label::before {
-						transform: rotate(90deg);
-					}
-
-					summary {
-						display: flex;
-						align-items: center;
-						gap: 2px;
-
-						.text-label {
-							padding-left: 16px;
-							position: relative;
-							width: 100%;
-
-							&::before {
-								content: "";
-								position: absolute;
-								margin: auto;
-								top: 0;
-								bottom: 0;
-								left: 0;
-								width: 8px;
-								height: 8px;
-								background: var(--icon-expand-collapse-arrow);
-							}
-						}
-					}
-
-					.text-button {
-						width: 100%;
-						margin: 4px 0;
-					}
-				}
-			}
-
 			.toggle-layer-or-node .text-label {
 				line-height: 24px;
 				margin-right: 8px;
diff --git a/frontend/src/components/widgets/WidgetSection.svelte b/frontend/src/components/widgets/WidgetSection.svelte
index ec79988dc8..9f50d0e83e 100644
--- a/frontend/src/components/widgets/WidgetSection.svelte
+++ b/frontend/src/components/widgets/WidgetSection.svelte
@@ -27,8 +27,21 @@
 	<button class="header" class:expanded on:click|stopPropagation={() => (expanded = !expanded)} tabindex="0">
 		<div class="expand-arrow" />
 		<TextLabel bold={true}>{widgetData.name}</TextLabel>
+		{#if widgetData.pinned}
+			<IconButton
+				icon={"CheckboxChecked"}
+				tooltip={"Unpin this node so it's no longer shown here without a selection"}
+				size={24}
+				action={(e) => {
+					editor.handle.unpinNode(widgetData.id);
+					e?.stopPropagation();
+				}}
+				class={"show-only-on-hover"}
+			/>
+		{/if}
 		<IconButton
 			icon={"Trash"}
+			tooltip={"Delete this node from the layer chain"}
 			size={24}
 			action={(e) => {
 				editor.handle.deleteNode(widgetData.id);
@@ -39,6 +52,7 @@
 		<IconButton
 			icon={widgetData.visible ? "EyeVisible" : "EyeHidden"}
 			hoverIcon={widgetData.visible ? "EyeHide" : "EyeShow"}
+			tooltip={widgetData.visible ? "Hide this node" : "Show this node"}
 			size={24}
 			action={(e) => {
 				editor.handle.toggleNodeVisibilityLayerPanel(widgetData.id);
@@ -68,10 +82,7 @@
 	.widget-section {
 		flex: 0 0 auto;
 		margin: 0 4px;
-
-		+ .widget-section {
-			margin-top: 4px;
-		}
+		margin-top: 4px;
 
 		.header {
 			text-align: left;
diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte
index 6631867082..8e4c9b663f 100644
--- a/frontend/src/components/widgets/WidgetSpan.svelte
+++ b/frontend/src/components/widgets/WidgetSpan.svelte
@@ -6,6 +6,7 @@
 	import type { Widget, WidgetSpanColumn, WidgetSpanRow } from "@graphite/wasm-communication/messages";
 	import { narrowWidgetProps, isWidgetSpanColumn, isWidgetSpanRow } from "@graphite/wasm-communication/messages";
 
+	import NodeCatalog from "@graphite/components/floating-menus/NodeCatalog.svelte";
 	import BreadcrumbTrailButtons from "@graphite/components/widgets/buttons/BreadcrumbTrailButtons.svelte";
 	import ColorButton from "@graphite/components/widgets/buttons/ColorButton.svelte";
 	import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
@@ -127,6 +128,10 @@
 		{#if imageLabel}
 			<ImageLabel {...exclude(imageLabel)} />
 		{/if}
+		{@const nodeCatalog = narrowWidgetProps(component.props, "NodeCatalog")}
+		{#if nodeCatalog}
+			<NodeCatalog {...exclude(nodeCatalog)} on:selectNodeType={(e) => widgetValueCommitAndUpdate(index, e.detail)} />
+		{/if}
 		{@const numberInput = narrowWidgetProps(component.props, "NumberInput")}
 		{#if numberInput}
 			<NumberInput
diff --git a/frontend/src/io-managers/input.ts b/frontend/src/io-managers/input.ts
index 5dbcaaa9c8..484f4f735e 100644
--- a/frontend/src/io-managers/input.ts
+++ b/frontend/src/io-managers/input.ts
@@ -162,6 +162,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
 		const { target } = e;
 		const isTargetingCanvas = target instanceof Element && (target.closest("[data-viewport]") || target.closest("[data-node-graph]"));
 		const inDialog = target instanceof Element && target.closest("[data-dialog] [data-floating-menu-content]");
+		const inContextMenu = target instanceof Element && target.closest("[data-context-menu]");
 		const inTextInput = target === textToolInteractiveInputElement;
 
 		if (get(dialog).visible && !inDialog) {
@@ -170,7 +171,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
 			e.stopPropagation();
 		}
 
-		if (!inTextInput) {
+		if (!inTextInput && !inContextMenu) {
 			if (textToolInteractiveInputElement) editor.handle.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText));
 			else viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
 		}
diff --git a/frontend/src/utility-functions/icons.ts b/frontend/src/utility-functions/icons.ts
index f6d4f1561f..69abfb1d91 100644
--- a/frontend/src/utility-functions/icons.ts
+++ b/frontend/src/utility-functions/icons.ts
@@ -140,6 +140,7 @@ import NodeOutput from "@graphite-frontend/assets/icon-16px-solid/node-output.sv
 import NodeShape from "@graphite-frontend/assets/icon-16px-solid/node-shape.svg";
 import NodeText from "@graphite-frontend/assets/icon-16px-solid/node-text.svg";
 import NodeTransform from "@graphite-frontend/assets/icon-16px-solid/node-transform.svg";
+import Node from "@graphite-frontend/assets/icon-16px-solid/node.svg";
 import PadlockLocked from "@graphite-frontend/assets/icon-16px-solid/padlock-locked.svg";
 import PadlockUnlocked from "@graphite-frontend/assets/icon-16px-solid/padlock-unlocked.svg";
 import Paste from "@graphite-frontend/assets/icon-16px-solid/paste.svg";
@@ -205,6 +206,7 @@ const SOLID_16PX = {
 	Layer: { svg: Layer, size: 16 },
 	License: { svg: License, size: 16 },
 	NewLayer: { svg: NewLayer, size: 16 },
+	Node: { svg: Node, size: 16 },
 	NodeBlur: { svg: NodeBlur, size: 16 },
 	NodeBrushwork: { svg: NodeBrushwork, size: 16 },
 	NodeColorCorrection: { svg: NodeColorCorrection, size: 16 },
@@ -226,9 +228,9 @@ const SOLID_16PX = {
 	Reload: { svg: Reload, size: 16 },
 	Rescale: { svg: Rescale, size: 16 },
 	Reset: { svg: Reset, size: 16 },
+	Reverse: { svg: Reverse, size: 16 },
 	ReverseRadialGradientToLeft: { svg: ReverseRadialGradientToLeft, size: 16 },
 	ReverseRadialGradientToRight: { svg: ReverseRadialGradientToRight, size: 16 },
-	Reverse: { svg: Reverse, size: 16 },
 	Settings: { svg: Settings, size: 16 },
 	Stack: { svg: Stack, size: 16 },
 	Trash: { svg: Trash, size: 16 },
diff --git a/frontend/src/wasm-communication/messages.ts b/frontend/src/wasm-communication/messages.ts
index 7839a0d62f..07d2517f52 100644
--- a/frontend/src/wasm-communication/messages.ts
+++ b/frontend/src/wasm-communication/messages.ts
@@ -1116,6 +1116,10 @@ export class NumberInput extends WidgetProps {
 	minWidth!: number;
 }
 
+export class NodeCatalog extends WidgetProps {
+	disabled!: boolean;
+}
+
 export class PopoverButton extends WidgetProps {
 	style!: PopoverButtonStyle | undefined;
 
@@ -1293,6 +1297,7 @@ const widgetSubTypes = [
 	{ value: IconButton, name: "IconButton" },
 	{ value: IconLabel, name: "IconLabel" },
 	{ value: ImageLabel, name: "ImageLabel" },
+	{ value: NodeCatalog, name: "NodeCatalog" },
 	{ value: NumberInput, name: "NumberInput" },
 	{ value: ParameterExposeButton, name: "ParameterExposeButton" },
 	{ value: PivotInput, name: "PivotInput" },
@@ -1425,7 +1430,7 @@ export function isWidgetSpanRow(layoutRow: LayoutGroup): layoutRow is WidgetSpan
 	return Boolean((layoutRow as WidgetSpanRow)?.rowWidgets);
 }
 
-export type WidgetSection = { name: string; visible: boolean; id: bigint; layout: LayoutGroup[] };
+export type WidgetSection = { name: string; visible: boolean; pinned: boolean; id: bigint; layout: LayoutGroup[] };
 export function isWidgetSection(layoutRow: LayoutGroup): layoutRow is WidgetSection {
 	return Boolean((layoutRow as WidgetSection)?.layout);
 }
@@ -1468,6 +1473,7 @@ function createLayoutGroup(layoutGroup: any): LayoutGroup {
 		const result: WidgetSection = {
 			name: layoutGroup.section.name,
 			visible: layoutGroup.section.visible,
+			pinned: layoutGroup.section.pinned,
 			id: layoutGroup.section.id,
 			layout: layoutGroup.section.layout.map(createLayoutGroup),
 		};
@@ -1502,8 +1508,6 @@ export class UpdateMenuBarLayout extends JsMessage {
 
 export class UpdateNodeGraphBarLayout extends WidgetDiffUpdate {}
 
-export class UpdatePropertyPanelOptionsLayout extends WidgetDiffUpdate {}
-
 export class UpdatePropertyPanelSectionsLayout extends WidgetDiffUpdate {}
 
 export class UpdateToolOptionsLayout extends WidgetDiffUpdate {}
@@ -1594,7 +1598,6 @@ export const messageMakers: Record<string, MessageMaker> = {
 	UpdateNodeThumbnail,
 	UpdateNodeTypes,
 	UpdateOpenDocumentsList,
-	UpdatePropertyPanelOptionsLayout,
 	UpdatePropertyPanelSectionsLayout,
 	UpdateToolOptionsLayout,
 	UpdateToolShelfLayout,
diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs
index 04d5fcb1fd..00dcdcf015 100644
--- a/frontend/wasm/src/editor_api.rs
+++ b/frontend/wasm/src/editor_api.rs
@@ -652,20 +652,16 @@ impl EditorHandle {
 		self.dispatch(message);
 	}
 
+	/// Unpin a node given its node ID
+	#[wasm_bindgen(js_name = unpinNode)]
+	pub fn unpin_node(&self, id: u64) {
+		self.dispatch(DocumentMessage::SetNodePinned { node_id: NodeId(id), pinned: false });
+	}
+
 	/// Delete a layer or node given its node ID
 	#[wasm_bindgen(js_name = deleteNode)]
 	pub fn delete_node(&self, id: u64) {
-		let message = DocumentMessage::StartTransaction;
-		self.dispatch(message);
-
-		let id = NodeId(id);
-		self.dispatch(NodeGraphMessage::DeleteNodes {
-			node_ids: vec![id],
-			delete_children: true,
-		});
-		self.dispatch(NodeGraphMessage::RunDocumentGraph);
-		self.dispatch(NodeGraphMessage::SelectedNodesUpdated);
-		self.dispatch(NodeGraphMessage::SendGraph);
+		self.dispatch(DocumentMessage::DeleteNode { node_id: NodeId(id) });
 	}
 
 	/// Toggle lock state of a layer from the layer list
@@ -693,11 +689,7 @@ impl EditorHandle {
 	/// Toggle display type for a layer
 	#[wasm_bindgen(js_name = setToNodeOrLayer)]
 	pub fn set_to_node_or_layer(&self, id: u64, is_layer: bool) {
-		let node_id = NodeId(id);
-		let message = DocumentMessage::StartTransaction;
-		self.dispatch(message);
-		let message = NodeGraphMessage::SetToNodeOrLayer { node_id, is_layer };
-		self.dispatch(message);
+		self.dispatch(DocumentMessage::SetToNodeOrLayer { node_id: NodeId(id), is_layer });
 	}
 
 	#[wasm_bindgen(js_name = injectImaginatePollServerStatus)]
diff --git a/node-graph/graph-craft/src/document.rs b/node-graph/graph-craft/src/document.rs
index 743eff546a..b0a0f64c58 100644
--- a/node-graph/graph-craft/src/document.rs
+++ b/node-graph/graph-craft/src/document.rs
@@ -1016,7 +1016,7 @@ impl NodeNetwork {
 
 	/// Remove all nodes that contain [`DocumentNodeImplementation::Network`] by moving the nested nodes into the parent network.
 	pub fn flatten(&mut self, node_id: NodeId) {
-		self.flatten_with_fns(node_id, merge_ids, || NodeId::new())
+		self.flatten_with_fns(node_id, merge_ids, NodeId::new)
 	}
 
 	/// Remove all nodes that contain [`DocumentNodeImplementation::Network`] by moving the nested nodes into the parent network.


Test patch: