Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Make the node catalog, originating from a wire dropped in the graph, filter for valid types #2423

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
@@ -419,7 +419,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
.node_graph_handler
.context_menu
.as_ref()
.is_some_and(|context_menu| matches!(context_menu.context_menu_data, super::node_graph::utility_types::ContextMenuData::CreateNode))
.is_some_and(|context_menu| matches!(context_menu.context_menu_data, super::node_graph::utility_types::ContextMenuData::CreateNode { compatible_type: None }))
{
// Close the context menu
self.node_graph_handler.context_menu = None;
Original file line number Diff line number Diff line change
@@ -3441,11 +3441,96 @@ pub fn resolve_document_node_type(identifier: &str) -> Option<&DocumentNodeDefin
}

pub fn collect_node_types() -> Vec<FrontendNodeType> {
DOCUMENT_NODE_TYPES
// Create a mapping from registry ID to document node identifier
let id_to_identifier_map: HashMap<String, &'static str> = DOCUMENT_NODE_TYPES
.iter()
.filter_map(|definition| {
if let DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier { name }) = &definition.node_template.document_node.implementation {
Some((name.to_string(), definition.identifier))
} else {
None
}
})
.collect();
let mut extracted_node_types = Vec::new();

{
let node_registry = graphene_core::registry::NODE_REGISTRY.lock().unwrap();
let node_metadata = graphene_core::registry::NODE_METADATA.lock().unwrap();

for (id, metadata) in node_metadata.iter() {
if let Some(implementations) = node_registry.get(id) {
let identifier = match id_to_identifier_map.get(id) {
Some(&id) => id.to_string(),
None => {
continue;
}
};

// Extract category from metadata (already creates an owned String)
let category = metadata.category.unwrap_or("").to_string();

// Extract input types (already creates owned Strings)
let input_types = implementations
.iter()
.flat_map(|(_, node_io)| node_io.inputs.iter().map(|ty| ty.clone().nested_type().to_string()))
.collect::<HashSet<String>>()
.into_iter()
.collect::<Vec<String>>();

// Create a FrontendNodeType
let node_type = FrontendNodeType::with_owned_strings_and_input_types(identifier, category, input_types);

// Store the created node_type
extracted_node_types.push(node_type);
}
}
}
let node_types: Vec<FrontendNodeType> = DOCUMENT_NODE_TYPES
.iter()
.filter(|definition| !definition.category.is_empty())
.map(|definition| FrontendNodeType::new(definition.identifier, definition.category))
.collect()
.map(|definition| {
let input_types = definition
.node_template
.document_node
.inputs
.iter()
.filter_map(|node_input| {
if let Some(node_value) = node_input.as_value() {
Some(node_value.ty().nested_type().to_string())
} else {
None
}
})
.collect::<Vec<String>>();

FrontendNodeType::with_input_types(definition.identifier, definition.category, input_types)
})
.collect();

// Update categories in extracted_node_types from node_types
for extracted_node in &mut extracted_node_types {
if extracted_node.category.is_empty() {
// Find matching node in node_types and update category if found
if let Some(matching_node) = node_types.iter().find(|nt| nt.name == extracted_node.name) {
extracted_node.category = matching_node.category.clone();
}
}
}
let missing_nodes: Vec<FrontendNodeType> = node_types
.iter()
.filter(|node| !extracted_node_types.iter().any(|extracted| extracted.name == node.name))
.cloned()
.collect();

// Add the missing nodes to extracted_node_types
for node in missing_nodes {
extracted_node_types.push(node);
}
// Remove entries with empty categories
extracted_node_types.retain(|node| !node.category.is_empty());

extracted_node_types
}

pub fn collect_node_descriptions() -> Vec<(String, String)> {
Original file line number Diff line number Diff line change
@@ -298,6 +298,30 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
return;
}

let Some(network_metadata) = network_interface.network_metadata(selection_network_path) else {
log::error!("Could not get network metadata in EnterNestedNetwork");
return;
};

let click = ipp.mouse.position;
let node_graph_point = network_metadata.persistent_metadata.navigation_metadata.node_graph_to_viewport.inverse().transform_point2(click);

// Check if clicked on empty area (no node, no input/output connector)
let clicked_id = network_interface.node_from_click(click, selection_network_path);
let clicked_input = network_interface.input_connector_from_click(click, selection_network_path);
let clicked_output = network_interface.output_connector_from_click(click, selection_network_path);

if clicked_id.is_none() && clicked_input.is_none() && clicked_output.is_none() && self.context_menu.is_none() {
// Create a context menu with node creation options
self.context_menu = Some(ContextMenuInformation {
context_menu_coordinates: (node_graph_point.x as i32, node_graph_point.y as i32),
context_menu_data: ContextMenuData::CreateNode { compatible_type: None },
});

responses.add(FrontendMessage::UpdateContextMenuInformation {
context_menu_information: self.context_menu.clone(),
});
}
let Some(node_id) = network_interface.node_from_click(ipp.mouse.position, selection_network_path) else {
return;
};
@@ -611,11 +635,11 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
currently_is_node: !network_interface.is_layer(&node_id, selection_network_path),
}
} else {
ContextMenuData::CreateNode
ContextMenuData::CreateNode { compatible_type: None }
};

// TODO: Create function
let node_graph_shift = if matches!(context_menu_data, ContextMenuData::CreateNode) {
let node_graph_shift = if matches!(context_menu_data, ContextMenuData::CreateNode { compatible_type: None }) {
let appear_right_of_mouse = if click.x > ipp.viewport_bounds.size().x - 180. { -180. } else { 0. };
let appear_above_mouse = if click.y > ipp.viewport_bounds.size().y - 200. { -200. } else { 0. };
DVec2::new(appear_right_of_mouse, appear_above_mouse) / network_metadata.persistent_metadata.navigation_metadata.node_graph_to_viewport.matrix2.x_axis.x
@@ -999,14 +1023,31 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphHandlerData<'a>> for NodeGrap
warn!("No network_metadata");
return;
};

// Get the compatible type from the output connector
let compatible_type = if let Some(output_connector) = &output_connector {
if let Some(node_id) = output_connector.node_id() {
let output_index = output_connector.index();
// Get the output types from the network interface
let output_types = network_interface.output_types(&node_id, selection_network_path);

// Extract the type if available
output_types.get(output_index).and_then(|type_option| type_option.as_ref()).map(|(output_type, _)| {
// Create a search term based on the type
format!("type:{}", output_type.clone().nested_type())
})
} else {
None
}
} else {
None
};
let appear_right_of_mouse = if ipp.mouse.position.x > ipp.viewport_bounds.size().x - 173. { -173. } else { 0. };
let appear_above_mouse = if ipp.mouse.position.y > ipp.viewport_bounds.size().y - 34. { -34. } else { 0. };
let node_graph_shift = DVec2::new(appear_right_of_mouse, appear_above_mouse) / network_metadata.persistent_metadata.navigation_metadata.node_graph_to_viewport.matrix2.x_axis.x;

self.context_menu = Some(ContextMenuInformation {
context_menu_coordinates: ((point.x + node_graph_shift.x) as i32, (point.y + node_graph_shift.y) as i32),
context_menu_data: ContextMenuData::CreateNode,
context_menu_data: ContextMenuData::CreateNode { compatible_type },
});

responses.add(FrontendMessage::UpdateContextMenuInformation {
Original file line number Diff line number Diff line change
@@ -107,13 +107,31 @@ pub struct FrontendNodeWire {
pub struct FrontendNodeType {
pub name: String,
pub category: String,
#[serde(rename = "inputTypes")]
pub input_types: Option<Vec<String>>,
}

impl FrontendNodeType {
pub fn new(name: &'static str, category: &'static str) -> Self {
Self {
name: name.to_string(),
category: category.to_string(),
input_types: None,
}
}

pub fn with_input_types(name: &'static str, category: &'static str, input_types: Vec<String>) -> Self {
Self {
name: name.to_string(),
category: category.to_string(),
input_types: Some(input_types),
}
}
pub fn with_owned_strings_and_input_types(name: String, category: String, input_types: Vec<String>) -> Self {
Self {
name,
category,
input_types: Some(input_types),
}
}
}
@@ -162,7 +180,11 @@ pub enum ContextMenuData {
#[serde(rename = "currentlyIsNode")]
currently_is_node: bool,
},
CreateNode,
CreateNode {
#[serde(rename = "compatibleType")]
#[serde(default)]
compatible_type: Option<String>,
},
}

#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, specta::Type)]
51 changes: 41 additions & 10 deletions frontend/src/components/floating-menus/NodeCatalog.svelte
Original file line number Diff line number Diff line change
@@ -12,9 +12,11 @@
const nodeGraph = getContext<NodeGraphState>("nodeGraph");

export let disabled = false;
// Add prop for initial search term from compatible type
export let initialSearchTerm = "";

let nodeSearchInput: TextInput | undefined = undefined;
let searchTerm = "";
let searchTerm = initialSearchTerm;

$: nodeCategories = buildNodeCategories($nodeGraph.nodeTypes, searchTerm);

@@ -25,33 +27,62 @@

function buildNodeCategories(nodeTypes: FrontendNodeType[], searchTerm: string): [string, NodeCategoryDetails][] {
const categories = new Map<string, NodeCategoryDetails>();
const isTypeSearch = searchTerm.toLowerCase().startsWith("type:");
let typeSearchTerm = "";
let remainingSearchTerms = [];

if (isTypeSearch) {
// Extract the first word after "type:" as the type search
const searchParts = searchTerm.substring(5).trim().split(/\s+/);
typeSearchTerm = searchParts[0].toLowerCase();

remainingSearchTerms = searchParts.slice(1).map((term) => term.toLowerCase());
} else {
remainingSearchTerms = [searchTerm.toLowerCase()];
}

nodeTypes.forEach((node) => {
let nameIncludesSearchTerm = node.name.toLowerCase().includes(searchTerm.toLowerCase());
let matchesTypeSearch = true;
let matchesRemainingTerms = true;

if (isTypeSearch && typeSearchTerm) {
matchesTypeSearch = node.inputTypes?.some((inputType) => inputType.toLowerCase().includes(typeSearchTerm)) || false;
}

if (remainingSearchTerms.length > 0) {
matchesRemainingTerms = remainingSearchTerms.every((term) => {
const nameMatch = node.name.toLowerCase().includes(term);
const categoryMatch = node.category.toLowerCase().includes(term);

// Quick and dirty hack to alias "Layer" to "Merge" in the search
if (node.name === "Merge") {
nameIncludesSearchTerm = nameIncludesSearchTerm || "Layer".toLowerCase().includes(searchTerm.toLowerCase());
// Quick and dirty hack to alias "Layer" to "Merge" in the search
const layerAliasMatch = node.name === "Merge" && "layer".includes(term);

return nameMatch || categoryMatch || layerAliasMatch;
});
}

if (searchTerm.length > 0 && !nameIncludesSearchTerm && !node.category.toLowerCase().includes(searchTerm.toLowerCase())) {
// Node matches if it passes both type search and remaining terms filters
const includesSearchTerm = matchesTypeSearch && matchesRemainingTerms;

if (searchTerm.length > 0 && !includesSearchTerm) {
return;
}

const category = categories.get(node.category);
let open = nameIncludesSearchTerm;
let open = includesSearchTerm;
if (searchTerm.length === 0) {
open = false;
}

if (category) {
category.open = open;
category.open = category.open || open;
category.nodes.push(node);
} else
} else {
categories.set(node.category, {
open,
nodes: [node],
});
}
});

const START_CATEGORIES_ORDER = ["UNCATEGORIZED", "General", "Value", "Math", "Style"];
@@ -82,7 +113,7 @@
</script>

<div class="node-catalog">
<TextInput placeholder="Search Nodes..." value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
<TextInput placeholder="Search Nodes... (or type:InputType)" value={searchTerm} on:value={({ detail }) => (searchTerm = detail)} bind:this={nodeSearchInput} />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not quite sure of the UX of having the type as the search term. This prevents the user from further filtering down the options.

For example vector data is used by many nodes (and more will be added over time), so the user may wish to further filter them with a keyword.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, @0HyperCube pushed code changes that adds support for simultaneous type, category and node name search.

<div class="list-results" on:wheel|passive|stopPropagation>
{#each nodeCategories as nodeCategory}
<details open={nodeCategory[1].open}>
4 changes: 3 additions & 1 deletion frontend/src/components/views/Graph.svelte
Original file line number Diff line number Diff line change
@@ -653,8 +653,10 @@
top: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates.y * $nodeGraph.transform.scale + $nodeGraph.transform.y}px`,
}}
>
{#if $nodeGraph.contextMenuInformation.contextMenuData === "CreateNode"}
{#if typeof $nodeGraph.contextMenuInformation.contextMenuData === "string" && $nodeGraph.contextMenuInformation.contextMenuData === "CreateNode"}
<NodeCatalog on:selectNodeType={(e) => createNode(e.detail)} />
{:else if $nodeGraph.contextMenuInformation.contextMenuData && "compatibleType" in $nodeGraph.contextMenuInformation.contextMenuData}
<NodeCatalog initialSearchTerm={$nodeGraph.contextMenuInformation.contextMenuData.compatibleType || ""} on:selectNodeType={(e) => createNode(e.detail)} />
{:else}
{@const contextMenuData = $nodeGraph.contextMenuInformation.contextMenuData}
<LayoutRow class="toggle-layer-or-node">
7 changes: 5 additions & 2 deletions frontend/src/messages.ts
Original file line number Diff line number Diff line change
@@ -46,6 +46,8 @@ const ContextTupleToVec2 = Transform((data) => {
let contextMenuData = data.obj.contextMenuInformation.contextMenuData;
if (contextMenuData.ToggleLayer !== undefined) {
contextMenuData = { nodeId: contextMenuData.ToggleLayer.nodeId, currentlyIsNode: contextMenuData.ToggleLayer.currentlyIsNode };
} else if (contextMenuData.CreateNode !== undefined) {
contextMenuData = { type: "CreateNode", compatibleType: contextMenuData.CreateNode.compatibleType };
}
return { contextMenuCoordinates, contextMenuData };
});
@@ -185,8 +187,7 @@ export type FrontendClickTargets = {

export type ContextMenuInformation = {
contextMenuCoordinates: XY;

contextMenuData: "CreateNode" | { nodeId: bigint; currentlyIsNode: boolean };
contextMenuData: "CreateNode" | { type: "CreateNode"; compatibleType: string } | { nodeId: bigint; currentlyIsNode: boolean };
};

export type FrontendGraphDataType = "General" | "Raster" | "VectorData" | "Number" | "Group" | "Artboard";
@@ -337,6 +338,8 @@ export class FrontendNodeType {
readonly name!: string;

readonly category!: string;

readonly inputTypes!: string[];
}

export class NodeGraphTransform {
3 changes: 3 additions & 0 deletions node-graph/gcore/src/types.rs
Original file line number Diff line number Diff line change
@@ -345,4 +345,7 @@ impl ProtoNodeIdentifier {
pub const fn new(name: &'static str) -> Self {
ProtoNodeIdentifier { name: Cow::Borrowed(name) }
}
pub fn with_owned_string(name: String) -> Self {
ProtoNodeIdentifier { name: Cow::Owned(name) }
}
}
Loading