Add a node insertion button and layer renaming from the Properties panel (#2072)

* Add node button

* Improve css a bit

* Add layer renaming to the Properties panel and move New Layer to that, plus add unpinning to properties sections

* Add tooltip

* Re-add layer itself in listing

* Final code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
James Lindsay
2024-10-25 23:58:34 -07:00
committed by GitHub
co-authored by Keavon Chambers
parent 3c839ffd2b
commit 5aa6716910
24 changed files with 389 additions and 264 deletions
@@ -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>
@@ -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>
+3 -143
View File
@@ -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;
@@ -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;
@@ -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
+2 -1
View File
@@ -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;
}
+3 -1
View File
@@ -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 },
+7 -4
View File
@@ -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,