Generalize layers as merge nodes to enable adjustment layers (#1712)

* WIP, backward traversal issues

* Fix some tool issues

* Remove debugging

* Change some indices

* WIP: new artboard node

* WIP: add artboard node

* WIP: Artboard node and create_artboard

* WIP: Artboard node implementation complete

* WIP: Artboards input for output node

* Complete Artboard node

* Generalize LayerNodeIdentifier,  monitor_nodes support for Artboard node, adjust ResizeArtboard/ClearArtboards, move alias validation to Rust

* Fix misaligned artboard click targets

* Generalize/clarify create_layer and insert_between

* non-negative dimensions for resize_artboard

* Show artboards in layer panel

* Generalize create_layer for layer output node

* Generalize delete_layer/delete_artboard to NodeGraphMessage::DeleteNodes. Fixed upstream flow Iter

* remove old primary_input function

* Vertical node visuals, remove is_layer function, rename Layer node to Merge node, toggle display as layer

exposed_value_count type fix

Vertical node visuals, remove is_layer function, rename Layer node to Merge node, toggle display as layer

* Fix demo artwork

* Layer display context menu

* Automatically select artboard, fix warnings

* Improvements to context menu and layer invariant enforcement

* Remove display_as_layer and update load_structure

* Improve load_structure to show more layers, improve FlowIter, improve layer naming, layer rearrangement validation.

* Clean up demo artwork using generalized layers

* Improve design of Layers panel and graph nodes

* MoveSelectedLayersTo rewrite to support generalized layer nodes

* Include artboards in deepest_common_ancestor, fix resize_artboard/delete_artboard, sync artboard tool to layer panel

* MoveSelectedLayersTo adjustments

* Sync non layer node visibility with metadata

* Include non layer nodes when moving/creating layer

* Fix group layers and get_post_node_with_index

* Include non layer nodes in UngroupSelectedLayers

* GroupSelected for all selected nodes, UnGroupSelected position adjustments

* Add grouping for layers in different folders

* Fix hidden layers

* Prevent node from connecting to itself, fix undo automatic node insertion,

* Fix undo CreateEmptyFolder, fix grouping nested layer nodes

* Formatting

* Remove test and check if node is layer from network

* Fix undo group layers

* Check off roadmap

* MoveUpstreamSiblingsToChild adjustments

* Replace tabs with spaces, remove mut from argument

* Final code review pass

---------

Co-authored-by: 0hypercube <0hypercube@gmail.com>
Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
adamgerhant 2024-04-30 23:03:42 -07:00 committed by GitHub
parent beb88d280c
commit 8d83fa7079
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1712 additions and 825 deletions

View file

@ -0,0 +1,4 @@
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M15.4,9.7l-1.9-1-1.2.7,2,1.1-6,3.3c-.3.1-.6.1-.9,0l-6-3.3,2-1.1-1.2-.7-1.9,1c-.7.4-.7,1.1,0,1.5l6.6,3.6c.6.3,1.2.3,1.8,0l6.6-3.6c.7-.4.7-1.1,0-1.5Z" />
<path d="M7.1,9.9L.6,6.3c-.7-.4-.7-1.1,0-1.5L7.1,1.2c.6-.3,1.2-.3,1.8,0l6.6,3.6c.7.4.7,1.1,0,1.5l-6.6,3.6c-.6.3-1.2.3-1.8,0Z" />
</svg>

After

Width:  |  Height:  |  Size: 360 B

View file

@ -34,7 +34,7 @@ TypeScript files which serve as the JS interface to the WASM bindings for the ed
Instantiates the WASM and editor backend instances. The function `initWasm()` asynchronously constructs and initializes an instance of the WASM bindings JS module provided by wasm-bindgen/wasm-pack. The function `createEditor()` constructs an instance of the editor backend. In theory there could be multiple editor instances sharing the same WASM module instance. The function returns an object where `raw` is the WASM module, `instance` is the editor, and `subscriptions` is the subscription router (described below).
`initWasm()` occurs in `main.ts` right before the Svelte application exists, then `createEditor()` is run in `Editor.svelte` during the Svelte app's creation. Similarly to the state providers described above, the editor is given via `setContext()` so other components can get it via `getContext` and call functions on `editor.raw`, `editor.instance`, or `editor.subscriptions`.
`initWasm()` occurs in `main.ts` right before the Svelte application exists, then `createEditor()` is run in `Editor.svelte` during the Svelte app's creation. Similarly to the state providers described above, the editor is given via `setContext()` so other components can get it via `getContext` and call functions on `editor.raw`, `editor.handle`, or `editor.subscriptions`.
### Message definitions: `messages.ts`

View file

@ -138,6 +138,8 @@
linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(#ffffff, #ffffff);
--color-transparent-checkered-background-size: 16px 16px;
--color-transparent-checkered-background-position: 0 0, 8px 8px;
--color-transparent-checkered-background-size-mini: 8px 8px;
--color-transparent-checkered-background-position-mini: 0 0, 4px 4px;
--background-inactive-stripes: repeating-linear-gradient(
-45deg,

View file

@ -6,7 +6,7 @@
import { platformIsMac } from "@graphite/utility-functions/platform";
import type { Editor } from "@graphite/wasm-communication/editor";
import { defaultWidgetLayout, patchWidgetLayout, UpdateDocumentLayerDetails, UpdateDocumentLayerStructureJs, UpdateLayersPanelOptionsLayout } from "@graphite/wasm-communication/messages";
import type { DataBuffer, LayerClassification, LayerPanelEntry } from "@graphite/wasm-communication/messages";
import type { DataBuffer, LayerPanelEntry } from "@graphite/wasm-communication/messages";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
@ -165,7 +165,7 @@
const name = (e.target instanceof HTMLInputElement && e.target.value) || "";
editor.handle.setLayerName(listing.entry.id, name);
listing.entry.name = name;
listing.entry.alias = name;
}
async function onEditLayerNameDeselect(listing: LayerListingInfo) {
@ -174,7 +174,7 @@
layers = layers;
// Set it back to the original name if the user didn't enter a new name
if (document.activeElement instanceof HTMLInputElement) document.activeElement.value = listing.entry.name;
if (document.activeElement instanceof HTMLInputElement) document.activeElement.value = listing.entry.alias;
// Deselect the text so it doesn't appear selected while the input field becomes disabled and styled to look like regular text
window.getSelection()?.removeAllRanges();
@ -203,10 +203,6 @@
editor.handle.deselectAllLayers();
}
function isNestingLayer(layerClassification: LayerClassification) {
return layerClassification === "Folder" || layerClassification === "Artboard";
}
function calculateDragIndex(tree: LayoutCol, clientY: number, select?: () => void): DraggingData {
const treeChildren = tree.div()?.children;
const treeOffset = tree.div()?.getBoundingClientRect().top;
@ -248,7 +244,7 @@
}
// Inserting below current row
else if (distance > -closest && distance > -RANGE_TO_INSERT_WITHIN_BOTTOM_FOLDER_NOT_ROOT && distance < 0) {
if (isNestingLayer(layer.layerClassification)) {
if (layer.childrenAllowed) {
insertParentId = layer.id;
insertDepth = layer.depth;
insertIndex = 0;
@ -381,7 +377,6 @@
classes={{
selected: fakeHighlight !== undefined ? fakeHighlight === listing.entry.id : $nodeGraph.selected.includes(listing.entry.id),
"insert-folder": (draggingData?.highlightFolder || false) && draggingData?.insertParentId === listing.entry.id,
"nesting-layer": isNestingLayer(listing.entry.layerClassification),
}}
styles={{ "--layer-indent-levels": `${listing.entry.depth - 1}` }}
data-layer
@ -391,32 +386,29 @@
on:dragstart={(e) => draggable && dragStart(e, listing)}
on:click={(e) => selectLayerWithModifiers(e, listing)}
>
{#if isNestingLayer(listing.entry.layerClassification)}
{#if listing.entry.childrenAllowed}
<button
class="expand-arrow"
class:expanded={listing.entry.expanded}
disabled={!listing.entry.hasChildren}
disabled={!listing.entry.childrenPresent}
on:click|stopPropagation={() => handleExpandArrowClick(listing.entry.id)}
tabindex="0"
/>
{#if listing.entry.layerClassification === "Artboard"}
<IconLabel icon="Artboard" class={"layer-type-icon"} />
{:else if listing.entry.layerClassification === "Folder"}
<IconLabel icon="Folder" class={"layer-type-icon"} />
{/if}
<div class="thumbnail">
{#if $nodeGraph.thumbnails.has(listing.entry.id)}
{@html $nodeGraph.thumbnails.get(listing.entry.id)}
{/if}
{:else}
<div class="thumbnail">
{#if $nodeGraph.thumbnails.has(listing.entry.id)}
{@html $nodeGraph.thumbnails.get(listing.entry.id)}
{/if}
</div>
</div>
{#if listing.entry.name === "Artboard"}
<IconLabel icon="Artboard" class={"layer-type-icon"} />
{/if}
<LayoutRow class="layer-name" on:dblclick={() => onEditLayerName(listing)}>
<input
data-text-input
type="text"
value={listing.entry.name}
placeholder={listing.entry.layerClassification}
value={listing.entry.alias}
placeholder={listing.entry.name}
disabled={!listing.editingName}
on:blur={() => onEditLayerNameDeselect(listing)}
on:keydown={(e) => e.key === "Escape" && onEditLayerNameDeselect(listing)}
@ -503,11 +495,7 @@
border-radius: 2px;
height: 32px;
margin: 0 4px;
padding-left: calc(4px + var(--layer-indent-levels) * 16px);
&.nesting-layer {
padding-left: calc(var(--layer-indent-levels) * 16px);
}
padding-left: calc(var(--layer-indent-levels) * 16px);
&.selected {
background: var(--color-4-dimgray);
@ -557,17 +545,19 @@
}
}
.layer-type-icon {
flex: 0 0 auto;
margin-left: 4px;
}
.thumbnail {
width: 36px;
height: 24px;
background: white;
margin-left: 4px;
border-radius: 2px;
flex: 0 0 auto;
background: var(--color-transparent-checkered-background);
background-size: var(--color-transparent-checkered-background-size-mini);
background-position: var(--color-transparent-checkered-background-position-mini);
&:first-child {
margin-left: 20px;
}
svg {
width: calc(100% - 4px);
@ -576,6 +566,12 @@
}
}
.layer-type-icon {
flex: 0 0 auto;
margin-left: 8px;
margin-right: -4px;
}
.layer-name {
flex: 1 1 100%;
margin: 0 8px;

View file

@ -9,8 +9,10 @@
import type { FrontendNodeLink, FrontendNodeType, FrontendNode, FrontendGraphInput, FrontendGraphOutput } from "@graphite/wasm-communication/messages";
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";
@ -47,7 +49,9 @@
let disconnecting: { nodeId: bigint; inputIndex: number; linkIndex: number } | undefined = undefined;
let nodeLinkPaths: LinkPath[] = [];
let searchTerm = "";
let nodeListLocation: { x: number; y: number } | undefined = undefined;
let contextMenuOpenCoordinates: { x: number; y: number } | undefined = undefined;
let toggleDisplayAsLayerNodeId: bigint | undefined = undefined;
let toggleDisplayAsLayerCurrentlyIsNode: boolean = false;
let inputs: SVGSVGElement[][] = [];
let outputs: SVGSVGElement[][] = [];
@ -58,8 +62,8 @@
$: gridSpacing = calculateGridSpacing(transform.scale);
$: dotRadius = 1 + Math.floor(transform.scale - 0.5 + 0.001) / 2;
$: nodeCategories = buildNodeCategories($nodeGraph.nodeTypes, searchTerm);
$: nodeListX = ((nodeListLocation?.x || 0) + transform.x) * transform.scale;
$: nodeListY = ((nodeListLocation?.y || 0) + transform.y) * transform.scale;
$: contextMenuX = ((contextMenuOpenCoordinates?.x || 0) + transform.x) * transform.scale;
$: contextMenuY = ((contextMenuOpenCoordinates?.y || 0) + transform.y) * transform.scale;
let appearAboveMouse = false;
let appearRightOfMouse = false;
@ -69,8 +73,8 @@
if (!bounds) return;
const { width, height } = bounds;
appearRightOfMouse = nodeListX > width - ADD_NODE_MENU_WIDTH;
appearAboveMouse = nodeListY > height - ADD_NODE_MENU_HEIGHT;
appearRightOfMouse = contextMenuX > width - ADD_NODE_MENU_WIDTH;
appearAboveMouse = contextMenuY > height - ADD_NODE_MENU_HEIGHT;
})();
$: linkPathInProgress = createLinkPathInProgress(linkInProgressFromConnector, linkInProgressToConnector);
@ -127,7 +131,7 @@
const to = linkInProgressToConnector instanceof SVGSVGElement ? connectorToNodeIndex(linkInProgressToConnector) : undefined;
const linkStart = $nodeGraph.nodes.find((n) => n.id === from?.nodeId)?.isLayer || false;
const linkEnd = ($nodeGraph.nodes.find((n) => n.id === to?.nodeId)?.isLayer && to?.index !== 0) || false;
const linkEnd = ($nodeGraph.nodes.find((n) => n.id === to?.nodeId)?.isLayer && to?.index == 0) || false;
return createWirePath(linkInProgressFromConnector, linkInProgressToConnector, linkStart, linkEnd);
}
return undefined;
@ -169,7 +173,7 @@
if (disconnecting?.linkIndex === index) return [];
const linkStart = $nodeGraph.nodes.find((n) => n.id === link.linkStart)?.isLayer || false;
const linkEnd = ($nodeGraph.nodes.find((n) => n.id === link.linkEnd)?.isLayer && link.linkEndInputIndex !== 0n) || false;
const linkEnd = ($nodeGraph.nodes.find((n) => n.id === link.linkEnd)?.isLayer && Number(link.linkEndInputIndex) == 0) || false;
return [createWirePath(nodeOutput, nodeInput.getBoundingClientRect(), linkStart, linkEnd)];
});
@ -253,8 +257,9 @@
function createWirePath(outputPort: SVGSVGElement, inputPort: SVGSVGElement | DOMRect, verticalOut: boolean, verticalIn: boolean): LinkPath {
const inputPortRect = inputPort instanceof DOMRect ? inputPort : inputPort.getBoundingClientRect();
const outputPortRect = outputPort.getBoundingClientRect();
const pathString = buildWirePathString(outputPort.getBoundingClientRect(), inputPortRect, verticalOut, verticalIn);
const pathString = buildWirePathString(outputPortRect, inputPortRect, verticalOut, verticalIn);
const dataType = outputPort.getAttribute("data-datatype") || "general";
return { pathString, dataType, thick: verticalIn && verticalOut };
@ -310,7 +315,7 @@
function keydown(e: KeyboardEvent) {
if (e.key.toLowerCase() === "escape") {
nodeListLocation = undefined;
contextMenuOpenCoordinates = undefined;
document.removeEventListener("keydown", keydown);
linkInProgressFromConnector = undefined;
// linkInProgressFromLayerTop = undefined;
@ -319,7 +324,7 @@
}
function loadNodeList(e: PointerEvent, graphBounds: DOMRect) {
nodeListLocation = {
contextMenuOpenCoordinates = {
x: (e.clientX - graphBounds.x) / transform.scale - transform.x,
y: (e.clientY - graphBounds.y) / transform.scale - transform.y,
};
@ -340,23 +345,33 @@
const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined;
const nodeIdString = node?.getAttribute("data-node") || undefined;
const nodeId = nodeIdString ? BigInt(nodeIdString) : undefined;
const nodeList = (e.target as HTMLElement).closest("[data-node-list]") as HTMLElement | undefined;
const contextMenu = (e.target as HTMLElement).closest("[data-context-menu]") as HTMLElement | undefined;
// Create the add node popup on right click, then exit
if (rmb) {
toggleDisplayAsLayerNodeId = undefined;
if (node) {
toggleDisplayAsLayerNodeId = nodeId;
toggleDisplayAsLayerCurrentlyIsNode = !($nodeGraph.nodes.find((node) => node.id === nodeId)?.isLayer || false);
}
const graphBounds = graph?.getBoundingClientRect();
if (!graphBounds) return;
loadNodeList(e, graphBounds);
return;
}
// If the user is clicking on the add nodes list, exit here
if (lmb && nodeList) return;
// If the user is clicking on the add nodes list or context menu, exit here
if (lmb && contextMenu) return;
// Since the user is clicking elsewhere in the graph, ensure the add nodes list is closed
if (lmb) {
nodeListLocation = undefined;
contextMenuOpenCoordinates = undefined;
linkInProgressFromConnector = undefined;
toggleDisplayAsLayerNodeId = undefined;
// linkInProgressFromLayerTop = undefined;
// linkInProgressFromLayerBottom = undefined;
}
@ -474,7 +489,7 @@
if (panning) {
transform.x += e.movementX / transform.scale;
transform.y += e.movementY / transform.scale;
} else if (linkInProgressFromConnector && !nodeListLocation) {
} else if (linkInProgressFromConnector && !contextMenuOpenCoordinates) {
const target = e.target as Element | undefined;
const dot = (target?.closest(`[data-port="input"]`) || undefined) as SVGSVGElement | undefined;
if (dot) {
@ -545,6 +560,20 @@
editor.handle.toggleLayerVisibility(id);
}
function toggleLayerDisplay(displayAsLayer: boolean) {
let node = $nodeGraph.nodes.find((node) => node.id === toggleDisplayAsLayerNodeId);
if (node !== undefined) {
contextMenuOpenCoordinates = undefined;
editor.handle.setToNodeOrLayer(node.id, displayAsLayer);
toggleDisplayAsLayerCurrentlyIsNode = !($nodeGraph.nodes.find((node) => node.id === toggleDisplayAsLayerNodeId)?.isLayer || false);
toggleDisplayAsLayerNodeId = undefined;
}
}
function canBeToggledBetweenNodeAndLayer(toggleDisplayAsLayerNodeId: bigint) {
return $nodeGraph.nodes.find((node) => node.id === toggleDisplayAsLayerNodeId)?.canBeLayer || false;
}
function connectorToNodeIndex(svg: SVGSVGElement): { nodeId: bigint; index: number } | undefined {
const node = svg.closest("[data-node]");
@ -568,7 +597,7 @@
const selectedNodeId = $nodeGraph.selected[0];
const selectedNode = nodesContainer?.querySelector(`[data-node="${String(selectedNodeId)}"]`) || undefined;
// Check that neither the input or output of the selected node are already connected.
// Check that neither the primary input or output of the selected node are already connected.
const notConnected = $nodeGraph.links.findIndex((link) => link.linkStart === selectedNodeId || (link.linkEnd === selectedNodeId && link.linkEndInputIndex === BigInt(0))) === -1;
const input = selectedNode?.querySelector(`[data-port="input"]`) || undefined;
const output = selectedNode?.querySelector(`[data-port="output"]`) || undefined;
@ -589,13 +618,16 @@
const selectedNodeBounds = selectedNode.getBoundingClientRect();
const containerBoundsBounds = theNodesContainer.getBoundingClientRect();
return editor.handle.rectangleIntersects(
new Float64Array(wireCurveLocations.map((loc) => loc.x)),
new Float64Array(wireCurveLocations.map((loc) => loc.y)),
selectedNodeBounds.top - containerBoundsBounds.y,
selectedNodeBounds.left - containerBoundsBounds.x,
selectedNodeBounds.bottom - containerBoundsBounds.y,
selectedNodeBounds.right - containerBoundsBounds.x,
return (
link.linkEnd != selectedNodeId &&
editor.handle.rectangleIntersects(
new Float64Array(wireCurveLocations.map((loc) => loc.x)),
new Float64Array(wireCurveLocations.map((loc) => loc.y)),
selectedNodeBounds.top - containerBoundsBounds.y,
selectedNodeBounds.left - containerBoundsBounds.x,
selectedNodeBounds.bottom - containerBoundsBounds.y,
selectedNodeBounds.right - containerBoundsBounds.x,
)
);
});
@ -603,8 +635,7 @@
if (link) {
const isLayer = $nodeGraph.nodes.find((n) => n.id === selectedNodeId)?.isLayer;
editor.handle.connectNodesByLink(link.linkStart, 0, selectedNodeId, isLayer ? 1 : 0);
editor.handle.connectNodesByLink(selectedNodeId, 0, link.linkEnd, Number(link.linkEndInputIndex));
editor.handle.insertNodeBetween(link.linkEnd, Number(link.linkEndInputIndex), 0, selectedNodeId, 0, Number(link.linkStartOutputIndex), link.linkStart);
if (!isLayer) editor.handle.shiftNode(selectedNodeId);
}
}
@ -629,17 +660,17 @@
}
} else if (linkInProgressFromConnector && !initialDisconnecting) {
// If the add node menu is already open, we don't want to open it again
if (nodeListLocation) return;
if (contextMenuOpenCoordinates) return;
const graphBounds = graph?.getBoundingClientRect();
if (!graphBounds) return;
// Create the node list, which should set nodeListLocation to a valid value
loadNodeList(e, graphBounds);
if (!nodeListLocation) return;
let nodeListLocation2: { x: number; y: number } = nodeListLocation;
if (!contextMenuOpenCoordinates) return;
let contextMenuLocation2: { x: number; y: number } = contextMenuOpenCoordinates;
linkInProgressToConnector = new DOMRect((nodeListLocation2.x + transform.x) * transform.scale + graphBounds.x, (nodeListLocation2.y + transform.y) * transform.scale + graphBounds.y);
linkInProgressToConnector = new DOMRect((contextMenuLocation2.x + transform.x) * transform.scale + graphBounds.x, (contextMenuLocation2.y + transform.y) * transform.scale + graphBounds.y);
return;
} else if (draggingNodes) {
@ -665,13 +696,13 @@
}
function createNode(nodeType: string) {
if (!nodeListLocation) return;
if (!contextMenuOpenCoordinates) return;
const inputNodeConnectionIndex = 0;
const x = Math.round(nodeListLocation.x / GRID_SIZE);
const y = Math.round(nodeListLocation.y / GRID_SIZE) - 1;
const x = Math.round(contextMenuOpenCoordinates.x / GRID_SIZE);
const y = Math.round(contextMenuOpenCoordinates.y / GRID_SIZE) - 1;
const inputConnectedNodeID = editor.handle.createNode(nodeType, x, y);
nodeListLocation = undefined;
contextMenuOpenCoordinates = undefined;
if (!linkInProgressFromConnector) return;
const from = connectorToNodeIndex(linkInProgressFromConnector);
@ -702,17 +733,22 @@
return borderMask(boxes, nodeWidth, nodeHeight);
}
function layerBorderMask(nodeWidth: number): string {
function layerBorderMask(nodeWidthFromThumbnail: number, nodeChainAreaLeftExtension: number): string {
const NODE_HEIGHT = 2 * 24;
const THUMBNAIL_WIDTH = 72 + 8 * 2;
const FUDGE_HEIGHT_BEYOND_LAYER_HEIGHT = 2;
const nodeWidth = nodeWidthFromThumbnail + nodeChainAreaLeftExtension;
const boxes: { x: number; y: number; width: number; height: number }[] = [];
// Left input
boxes.push({ x: -8, y: 16, width: 16, height: 16 });
if (nodeChainAreaLeftExtension > 0) {
boxes.push({ x: -8, y: 16, width: 16, height: 16 });
}
// Thumbnail
boxes.push({ x: 28, y: -FUDGE_HEIGHT_BEYOND_LAYER_HEIGHT, width: THUMBNAIL_WIDTH, height: NODE_HEIGHT + FUDGE_HEIGHT_BEYOND_LAYER_HEIGHT * 2 });
boxes.push({ x: nodeChainAreaLeftExtension - 8, y: -FUDGE_HEIGHT_BEYOND_LAYER_HEIGHT, width: THUMBNAIL_WIDTH, height: NODE_HEIGHT + FUDGE_HEIGHT_BEYOND_LAYER_HEIGHT * 2 });
// Right visibility button
boxes.push({ x: nodeWidth - 12, y: (NODE_HEIGHT - 24) / 2, width: 24, height: 24 });
@ -745,33 +781,63 @@
style:--dot-radius={`${dotRadius}px`}
>
<!-- Right click menu for adding nodes -->
{#if nodeListLocation}
{#if contextMenuOpenCoordinates}
<LayoutCol
class="node-list"
data-node-list
class="context-menu"
data-context-menu
styles={{
transform: `translate(${appearRightOfMouse ? -100 : 0}%, ${appearAboveMouse ? -100 : 0}%)`,
left: `${nodeListX}px`,
top: `${nodeListY}px`,
width: `${ADD_NODE_MENU_WIDTH}px`,
height: `${ADD_NODE_MENU_HEIGHT}px`,
left: `${contextMenuX}px`,
top: `${contextMenuY}px`,
...(toggleDisplayAsLayerNodeId === undefined
? {
transform: `translate(${appearRightOfMouse ? -100 : 0}%, ${appearAboveMouse ? -100 : 0}%)`,
width: `${ADD_NODE_MENU_WIDTH}px`,
height: `${ADD_NODE_MENU_HEIGHT}px`,
}
: {}),
}}
>
<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>
{#if toggleDisplayAsLayerNodeId === undefined}
<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>
{:else}
<LayoutRow class="toggle-layer-or-node">
<TextLabel>Display as</TextLabel>
<RadioInput
selectedIndex={toggleDisplayAsLayerCurrentlyIsNode ? 0 : 1}
entries={[
{
value: "node",
label: "Node",
action: () => {
toggleLayerDisplay(false);
},
},
{
value: "layer",
label: "Layer",
action: () => {
toggleLayerDisplay(true);
},
},
]}
disabled={!canBeToggledBetweenNodeAndLayer(toggleDisplayAsLayerNodeId)}
/>
</LayoutRow>
{/if}
</LayoutCol>
{/if}
<!-- Node connection links -->
@ -801,6 +867,7 @@
style:--data-color={`var(--color-data-${node.primaryOutput?.dataType || "general"})`}
style:--data-color-dim={`var(--color-data-${node.primaryOutput?.dataType || "general"}-dim)`}
style:--label-width={labelWidthGridCells}
style:--node-chain-area-left-extension={node.exposedInputs.length === 0 ? 0 : 1.5}
data-node={node.id}
bind:this={nodeElements[nodeIndex]}
>
@ -808,29 +875,6 @@
<span class="node-error faded" transition:fade={FADE_TRANSITION} data-node-error>{node.errors}</span>
<span class="node-error hover" transition:fade={FADE_TRANSITION} data-node-error>{node.errors}</span>
{/if}
<div class="node-chain" />
<!-- Layer input port (from left) -->
<div class="input ports">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
class="port"
data-port="input"
data-datatype={node.primaryInput?.dataType}
style:--data-color={`var(--color-data-${node.primaryInput?.dataType})`}
style:--data-color-dim={`var(--color-data-${node.primaryInput?.dataType}-dim)`}
bind:this={inputs[nodeIndex][0]}
>
{#if node.primaryInput}
<title>{`${dataTypeTooltip(node.primaryInput)}\nConnected to ${node.primaryInput?.connected || "nothing"}`}</title>
{/if}
{#if node.primaryInput?.connected}
<path d="M0,6.306A1.474,1.474,0,0,0,2.356,7.724L7.028,5.248c1.3-.687,1.3-1.809,0-2.5L2.356.276A1.474,1.474,0,0,0,0,1.694Z" fill="var(--data-color)" />
{:else}
<path d="M0,6.306A1.474,1.474,0,0,0,2.356,7.724L7.028,5.248c1.3-.687,1.3-1.809,0-2.5L2.356.276A1.474,1.474,0,0,0,0,1.694Z" fill="var(--data-color-dim)" />
{/if}
</svg>
</div>
<div class="thumbnail">
{#if $nodeGraph.thumbnails.has(node.id)}
{@html $nodeGraph.thumbnails.get(node.id)}
@ -847,10 +891,10 @@
style:--data-color-dim={`var(--color-data-${node.primaryOutput.dataType}-dim)`}
bind:this={outputs[nodeIndex][0]}
>
<title>{`${dataTypeTooltip(node.primaryOutput)}\nConnected to ${node.primaryOutput.connected || "nothing"}`}</title>
<title>{`${dataTypeTooltip(node.primaryOutput)}\nConnected to ${`${node.primaryOutput.connected}, port index ${node.primaryOutput.connectedIndex}` || "nothing"}`}</title>
{#if node.primaryOutput.connected}
<path d="M0,6.953l2.521,-1.694a2.649,2.649,0,0,1,2.959,0l2.52,1.694v5.047h-8z" fill="var(--data-color)" />
{#if $nodeGraph.nodes.find((n) => n.id === node.primaryOutput?.connected)?.isLayer}
{#if Number(node.primaryOutput?.connectedIndex) === 0 && $nodeGraph.nodes.find((n) => n.id === node.primaryOutput?.connected)?.isLayer}
<path d="M0,-3.5h8v8l-2.521,-1.681a2.666,2.666,0,0,0,-2.959,0l-2.52,1.681z" fill="var(--data-color-dim)" />
{/if}
{:else}
@ -864,15 +908,17 @@
viewBox="0 0 8 12"
class="port bottom"
data-port="input"
data-datatype={stackDataInput.dataType}
style:--data-color={`var(--color-data-${stackDataInput.dataType})`}
style:--data-color-dim={`var(--color-data-${stackDataInput.dataType}-dim)`}
bind:this={inputs[nodeIndex][1]}
data-datatype={node.primaryInput?.dataType}
style:--data-color={`var(--color-data-${node.primaryInput?.dataType})`}
style:--data-color-dim={`var(--color-data-${node.primaryInput?.dataType}-dim)`}
bind:this={inputs[nodeIndex][0]}
>
<title>{`${dataTypeTooltip(stackDataInput)}\nConnected to ${stackDataInput.connected || "nothing"}`}</title>
{#if stackDataInput.connected}
{#if node.primaryInput}
<title>{`${dataTypeTooltip(node.primaryInput)}\nConnected to ${node.primaryInput?.connected || "nothing"}`}</title>
{/if}
{#if node.primaryInput?.connected}
<path d="M0,0H8V8L5.479,6.319a2.666,2.666,0,0,0-2.959,0L0,8Z" fill="var(--data-color)" />
{#if $nodeGraph.nodes.find((n) => n.id === stackDataInput.connected)?.isLayer}
{#if $nodeGraph.nodes.find((n) => n.id === node.primaryInput?.connected)?.isLayer}
<path d="M0,10.95l2.52,-1.69c0.89,-0.6,2.06,-0.6,2.96,0l2.52,1.69v5.05h-8v-5.05z" fill="var(--data-color-dim)" />
{/if}
{:else}
@ -880,10 +926,32 @@
{/if}
</svg>
</div>
<!-- Layer input port (from left) -->
{#if node.exposedInputs.length > 0}
<div class="input ports">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
class="port"
data-port="input"
data-datatype={stackDataInput.dataType}
style:--data-color={`var(--color-data-${stackDataInput.dataType})`}
style:--data-color-dim={`var(--color-data-${stackDataInput.dataType}-dim)`}
bind:this={inputs[nodeIndex][1]}
>
<title>{`${dataTypeTooltip(stackDataInput)}\nConnected to ${stackDataInput.connected || "nothing"}`}</title>
{#if stackDataInput.connected}
<path d="M0,6.306A1.474,1.474,0,0,0,2.356,7.724L7.028,5.248c1.3-.687,1.3-1.809,0-2.5L2.356.276A1.474,1.474,0,0,0,0,1.694Z" fill="var(--data-color)" />
{:else}
<path d="M0,6.306A1.474,1.474,0,0,0,2.356,7.724L7.028,5.248c1.3-.687,1.3-1.809,0-2.5L2.356.276A1.474,1.474,0,0,0,0,1.694Z" fill="var(--data-color-dim)" />
{/if}
</svg>
</div>
{/if}
<div class="details">
<!-- TODO: Allow the user to edit the name, just like in the Layers panel -->
<span title={editor.handle.inDevelopmentMode() ? `Node ID: ${node.id}` : undefined} bind:offsetWidth={layerNameLabelWidths[String(node.id)]}>
{node.alias || "Layer"}
{node.alias}
</span>
</div>
<IconButton
@ -898,7 +966,10 @@
<defs>
<clipPath id={clipPathId}>
<!-- Keep this equation in sync with the equivalent one in the CSS rule for `.layer { width: ... }` below -->
<path clip-rule="evenodd" d={layerBorderMask(36 + 72 + 8 + 24 * Math.max(3, labelWidthGridCells) + 8 + 12 + extraWidthToReachGridMultiple)} />
<path
clip-rule="evenodd"
d={layerBorderMask(72 + 8 + 24 * Math.max(3, labelWidthGridCells) + 8 + 12 + extraWidthToReachGridMultiple, node.exposedInputs.length === 0 ? 0 : 36)}
/>
</clipPath>
</defs>
</svg>
@ -997,7 +1068,7 @@
style:--data-color-dim={`var(--color-data-${node.primaryOutput.dataType}-dim)`}
bind:this={outputs[nodeIndex][0]}
>
<title>{`${dataTypeTooltip(node.primaryOutput)}\nConnected to ${node.primaryOutput.connected || "nothing"}`}</title>
<title>{`${dataTypeTooltip(node.primaryOutput)}\nConnected to ${`${node.primaryOutput.connected}, port index ${node.primaryOutput.connectedIndex}` || "nothing"}`}</title>
{#if node.primaryOutput.connected}
<path d="M0,6.306A1.474,1.474,0,0,0,2.356,7.724L7.028,5.248c1.3-.687,1.3-1.809,0-2.5L2.356.276A1.474,1.474,0,0,0,0,1.694Z" fill="var(--data-color)" />
{:else}
@ -1016,7 +1087,7 @@
style:--data-color-dim={`var(--color-data-${parameter.dataType}-dim)`}
bind:this={outputs[nodeIndex][outputIndex + (node.primaryOutput ? 1 : 0)]}
>
<title>{`${dataTypeTooltip(parameter)}\nConnected to ${parameter.connected || "nothing"}`}</title>
<title>{`${dataTypeTooltip(parameter)}\nConnected to ${`${parameter.connected}, port index ${parameter.connectedIndex}` || "nothing"}`}</title>
{#if parameter.connected}
<path d="M0,6.306A1.474,1.474,0,0,0,2.356,7.724L7.028,5.248c1.3-.687,1.3-1.809,0-2.5L2.356.276A1.474,1.474,0,0,0,0,1.694Z" fill="var(--data-color)" />
{:else}
@ -1077,13 +1148,14 @@
mix-blend-mode: screen;
}
.node-list {
.context-menu {
width: max-content;
position: absolute;
box-sizing: border-box;
padding: 5px;
z-index: 3;
background-color: var(--color-3-darkgray);
border-radius: 4px;
.text-input {
flex: 0 0 auto;
@ -1093,11 +1165,15 @@
.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);
@ -1133,6 +1209,11 @@
}
}
}
.toggle-layer-or-node .text-label {
line-height: 24px;
margin-right: 8px;
}
}
.wires {
@ -1294,10 +1375,12 @@
.layer {
border-radius: 8px;
--half-visibility-button: 12px;
--extra-width-to-reach-grid-multiple: 8px;
--node-chain-area-left-extension: 0;
// Keep this equation in sync with the equivalent one in the Svelte template `<clipPath><path d="layerBorderMask(...)" /></clipPath>` above
width: calc(36px + 72px + 8px + 24px * Max(3, var(--label-width)) + 8px + var(--half-visibility-button) + var(--extra-width-to-reach-grid-multiple));
width: calc(72px + 8px + 24px * Max(3, var(--label-width)) + 8px + 12px + var(--extra-width-to-reach-grid-multiple));
padding-left: calc(var(--node-chain-area-left-extension) * 24px);
margin-left: calc((1.5 - var(--node-chain-area-left-extension)) * 24px);
&::after {
border: 1px solid var(--color-5-dullgray);
@ -1309,10 +1392,6 @@
background: rgba(66, 66, 66, 0.4);
}
.node-chain {
width: 36px;
}
.thumbnail {
background: var(--color-2-mildblack);
border: 1px solid var(--data-color-dim);
@ -1368,7 +1447,7 @@
.visibility {
position: absolute;
right: calc(-1 * var(--half-visibility-button));
right: -12px;
}
.visibility,

View file

@ -144,6 +144,7 @@ import Reload from "@graphite-frontend/assets/icon-16px-solid/reload.svg";
import Rescale from "@graphite-frontend/assets/icon-16px-solid/rescale.svg";
import Reset from "@graphite-frontend/assets/icon-16px-solid/reset.svg";
import Settings from "@graphite-frontend/assets/icon-16px-solid/settings.svg";
import Stack from "@graphite-frontend/assets/icon-16px-solid/stack.svg";
import Trash from "@graphite-frontend/assets/icon-16px-solid/trash.svg";
import ViewModeNormal from "@graphite-frontend/assets/icon-16px-solid/view-mode-normal.svg";
import ViewModeOutline from "@graphite-frontend/assets/icon-16px-solid/view-mode-outline.svg";
@ -217,6 +218,7 @@ const SOLID_16PX = {
Rescale: { svg: Rescale, size: 16 },
Reset: { svg: Reset, size: 16 },
Settings: { svg: Settings, size: 16 },
Stack: { svg: Stack, size: 16 },
Trash: { svg: Trash, size: 16 },
ViewModeNormal: { svg: ViewModeNormal, size: 16 },
ViewModeOutline: { svg: ViewModeOutline, size: 16 },

View file

@ -100,11 +100,15 @@ export class FrontendGraphOutput {
readonly resolvedType!: string | undefined;
readonly connected!: bigint | undefined;
readonly connectedIndex!: bigint | undefined;
}
export class FrontendNode {
readonly isLayer!: boolean;
readonly canBeLayer!: boolean;
readonly id!: bigint;
readonly alias!: string;
@ -606,16 +610,23 @@ export class UpdateDocumentLayerDetails extends JsMessage {
}
export class LayerPanelEntry {
id!: bigint;
name!: string;
alias!: string;
@Transform(({ value }: { value: string }) => value || undefined)
tooltip!: string | undefined;
layerClassification!: LayerClassification;
childrenAllowed!: boolean;
childrenPresent!: boolean;
expanded!: boolean;
hasChildren!: boolean;
@Transform(({ value }: { value: bigint }) => Number(value))
depth!: number;
visible!: boolean;
@ -626,15 +637,8 @@ export class LayerPanelEntry {
parentsUnlocked!: boolean;
parentId!: bigint | undefined;
id!: bigint;
@Transform(({ value }: { value: bigint }) => Number(value))
depth!: number;
}
export type LayerClassification = "Folder" | "Artboard" | "Layer";
export class DisplayDialogDismiss extends JsMessage {}
export class Font {

View file

@ -35,6 +35,7 @@ export default defineConfig({
svelte({
preprocess: [sveltePreprocess()],
onwarn(warning, defaultHandler) {
// NOTICE: Keep this list in sync with the list in `.vscode/settings.json`
const suppressed = ["css-unused-selector", "vite-plugin-svelte-css-no-scopable-elements", "a11y-no-static-element-interactions", "a11y-no-noninteractive-element-interactions"];
if (suppressed.includes(warning.code)) return;

View file

@ -523,8 +523,8 @@ impl EditorHandle {
#[wasm_bindgen(js_name = moveLayerInTree)]
pub fn move_layer_in_tree(&self, insert_parent_id: Option<u64>, insert_index: Option<usize>) {
let insert_parent_id = insert_parent_id.map(NodeId);
let parent = insert_parent_id.map(LayerNodeIdentifier::new_unchecked).unwrap_or_default();
let message = DocumentMessage::MoveSelectedLayersTo {
parent,
insert_index: insert_index.map(|x| x as isize).unwrap_or(-1),
@ -568,6 +568,30 @@ impl EditorHandle {
self.dispatch(message);
}
/// Inserts node in-between two other nodes
#[wasm_bindgen(js_name = insertNodeBetween)]
pub fn insert_node_between(
&self,
post_node_id: u64,
post_node_input_index: usize,
insert_node_output_index: usize,
insert_node_id: u64,
insert_node_input_index: usize,
pre_node_output_index: usize,
pre_node_id: u64,
) {
let message = NodeGraphMessage::InsertNodeBetween {
post_node_id: NodeId(post_node_id),
post_node_input_index,
insert_node_output_index,
insert_node_id: NodeId(insert_node_id),
insert_node_input_index,
pre_node_output_index,
pre_node_id: NodeId(pre_node_id),
};
self.dispatch(message);
}
/// Shifts the node and its children to stop nodes going on top of each other
#[wasm_bindgen(js_name = shiftNode)]
pub fn shift_node(&self, node_id: u64) {
@ -686,6 +710,14 @@ impl EditorHandle {
self.dispatch(message);
}
/// 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 = NodeGraphMessage::SetToNodeOrLayer { node_id, is_layer };
self.dispatch(message);
}
#[wasm_bindgen(js_name = injectImaginatePollServerStatus)]
pub fn inject_imaginate_poll_server_status(&self) {
self.dispatch(PortfolioMessage::ImaginatePollServerStatus);