openfree's picture
Deploy from GitHub repository
2409829 verified
<script lang="ts">
import { createEventDispatcher, getContext, onMount } from "svelte";
import type { FrontendNodeType } from "@graphite/messages";
import type { NodeGraphState } from "@graphite/state-providers/node-graph";
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;
export let initialSearchTerm = "";
let nodeSearchInput: TextInput | undefined = undefined;
let searchTerm = initialSearchTerm;
$: 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>();
const isTypeSearch = searchTerm.toLowerCase().startsWith("type:");
let typeSearchTerm = "";
let remainingSearchTerms = [searchTerm.toLowerCase()];
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());
}
nodeTypes.forEach((node) => {
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
const layerAliasMatch = node.name === "Merge" && "layer".includes(term);
return nameMatch || categoryMatch || layerAliasMatch;
});
}
// 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 = includesSearchTerm;
if (searchTerm.length === 0) {
open = false;
}
if (category) {
category.open = 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} tooltip={$nodeGraph.nodeDescriptions.get(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;
pointer-events: none;
&::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>