Files
Graphite/frontend/src/components/views/Graph.svelte

1508 lines
51 KiB
Svelte

<script lang="ts">
import { getContext, onDestroy, onMount, tick } from "svelte";
import { cubicInOut } from "svelte/easing";
import { fade } from "svelte/transition";
import NodeCatalog from "/src/components/floating-menus/NodeCatalog.svelte";
import FloatingMenu from "/src/components/layout/FloatingMenu.svelte";
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import IconButton from "/src/components/widgets/buttons/IconButton.svelte";
import TextButton from "/src/components/widgets/buttons/TextButton.svelte";
import TextLabel from "/src/components/widgets/labels/TextLabel.svelte";
import type { DocumentStore } from "/src/stores/document";
import type { NodeGraphStore } from "/src/stores/node-graph";
import { closeContextMenu } from "/src/stores/node-graph";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import type { EditorWrapper, FrontendGraphInput, FrontendGraphOutput, FrontendNode } from "/wrapper/pkg/graphite_wasm_wrapper";
const GRID_COLLAPSE_SPACING = 10;
const GRID_SIZE = 24;
const FADE_TRANSITION = { duration: 200, easing: cubicInOut };
const editor = getContext<EditorWrapper>("editor");
const nodeGraph = getContext<NodeGraphStore>("nodeGraph");
const nodeGraphTransform = nodeGraph.transformStore;
const nodeGraphImportsExports = nodeGraph.importsExportsStore;
const visibleNodes = nodeGraph.visibleNodesStore;
const nodeGraphWires = nodeGraph.wiresStore;
const documentState = getContext<DocumentStore>("document");
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
let graph: HTMLDivElement | undefined;
$: gridSpacing = calculateGridSpacing($nodeGraphTransform.scale);
$: gridDotRadius = 1 + Math.floor($nodeGraphTransform.scale - 0.5 + 0.001) / 2;
// Close the context menu when the graph view overlay is closed
$: if (!$documentState.graphViewOverlayOpen) closeContextMenu();
let inputElement: HTMLInputElement;
let hoveringImportIndex: number | undefined = undefined;
let hoveringExportIndex: number | undefined = undefined;
let editingNameImportIndex: number | undefined = undefined;
let editingNameExportIndex: number | undefined = undefined;
let editingNameNodeId: bigint | undefined = undefined;
let editingNameText = "";
function exportsToEdgeTextInputWidth() {
let exportTextDivs = document.querySelectorAll(`[data-export-text-edge]`);
let exportTextDiv = Array.from(exportTextDivs).find((div) => {
return div.getAttribute("data-index") === String(editingNameExportIndex);
});
if (!graph || !exportTextDiv) return "50px";
let distance = graph.getBoundingClientRect().right - exportTextDiv.getBoundingClientRect().right;
return distance - 15 + "px";
}
function importsToEdgeTextInputWidth() {
let importTextDivs = document.querySelectorAll(`[data-import-text-edge]`);
let importTextDiv = Array.from(importTextDivs).find((div) => {
return div.getAttribute("data-index") === String(editingNameImportIndex);
});
if (!graph || !importTextDiv) return "50px";
let distance = importTextDiv.getBoundingClientRect().left - graph.getBoundingClientRect().left;
return distance - 15 + "px";
}
function setEditingImportNameIndex(index: number, currentName: string) {
focusInput(currentName);
editingNameImportIndex = index;
}
function setEditingExportNameIndex(index: number, currentName: string) {
focusInput(currentName);
editingNameExportIndex = index;
}
async function focusInput(currentName: string) {
editingNameText = currentName;
await tick();
inputElement?.focus();
}
function setEditingImportName(event: Event) {
if (editingNameImportIndex !== undefined) {
if (!(event.target instanceof HTMLInputElement)) return;
let text = event.target.value;
editor.setImportName(editingNameImportIndex, text);
editingNameImportIndex = undefined;
}
}
function setEditingExportName(event: Event) {
if (editingNameExportIndex !== undefined) {
if (!(event.target instanceof HTMLInputElement)) return;
let text = event.target.value;
editor.setExportName(editingNameExportIndex, text);
editingNameExportIndex = undefined;
}
}
function commitEditingNodeName(event: Event) {
if (editingNameNodeId === undefined || !(event.target instanceof HTMLInputElement)) return;
editor.setLayerName(editingNameNodeId, event.target.value);
editingNameNodeId = undefined;
}
onMount(() => {
// Backend dispatches this when the user double-clicks a layer's name area
subscriptions.subscribeFrontendMessage("TriggerEditLayerNameInGraph", async (data) => {
const node = $nodeGraph.nodes.get(data.nodeId);
if (!node) return;
editingNameText = node.displayName;
editingNameNodeId = data.nodeId;
await tick();
inputElement?.focus();
inputElement?.select();
});
});
onDestroy(() => {
subscriptions.unsubscribeFrontendMessage("TriggerEditLayerNameInGraph");
});
function calculateGridSpacing(scale: number): number {
const dense = scale * GRID_SIZE;
let sparse = dense;
while (sparse > 0 && sparse < GRID_COLLAPSE_SPACING) {
sparse *= 2;
}
return sparse;
}
function createNode(identifier: string) {
if ($nodeGraph.contextMenuInformation === undefined) return;
editor.createNode(identifier, $nodeGraph.contextMenuInformation.nodeCreationCoordinates[0], $nodeGraph.contextMenuInformation.nodeCreationCoordinates[1]);
}
function nodeBorderMask(nodeWidth: number, primaryInputExists: boolean, exposedSecondaryInputs: number, primaryOutputExists: boolean, exposedSecondaryOutputs: number): string {
const nodeHeight = Math.max(1 + exposedSecondaryInputs, 1 + exposedSecondaryOutputs) * 24;
const boxes: { x: number; y: number; width: number; height: number }[] = [];
// Primary input
if (primaryInputExists) boxes.push({ x: -8, y: 4, width: 16, height: 16 });
// Secondary inputs
for (let i = 0; i < exposedSecondaryInputs; i++) boxes.push({ x: -8, y: 4 + (i + 1) * 24, width: 16, height: 16 });
// Primary output
if (primaryOutputExists) boxes.push({ x: nodeWidth - 8, y: 4, width: 16, height: 16 });
// Exposed outputs
for (let i = 0; i < exposedSecondaryOutputs; i++) boxes.push({ x: nodeWidth - 8, y: 4 + (i + 1) * 24, width: 16, height: 16 });
return borderMask(boxes, nodeWidth, nodeHeight);
}
function layerBorderMask(nodeWidthFromThumbnail: number, nodeChainAreaLeftExtension: number, hasLeftInputWire: boolean): 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
if (hasLeftInputWire && nodeChainAreaLeftExtension > 0) {
boxes.push({ x: -8, y: 16, width: 16, height: 16 });
}
// Thumbnail
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 });
return borderMask(boxes, nodeWidth, NODE_HEIGHT);
}
function borderMask(boxes: { x: number; y: number; width: number; height: number }[], nodeWidth: number, nodeHeight: number): string {
const rectangles = boxes.map((box) => `M${box.x},${box.y} L${box.x + box.width},${box.y} L${box.x + box.width},${box.y + box.height} L${box.x},${box.y + box.height}z`);
return `M-2,-2 L${nodeWidth + 2},-2 L${nodeWidth + 2},${nodeHeight + 2} L-2,${nodeHeight + 2}z ${rectangles.join(" ")}`;
}
function nodeNameTooltipLabel(node: FrontendNode): string {
return node.displayName === node.implementationName ? node.displayName : `${node.displayName} (${node.implementationName})`;
}
function validTypesText(value: FrontendGraphInput): string {
const validTypes = value.validTypes.length > 0 ? value.validTypes.map((x) => `• ${x}`).join("\n") : "None";
return `Valid Types:\n${validTypes}`;
}
function outputConnectedToText(output: FrontendGraphOutput): string {
return editor.inDevelopmentMode() ? output.connectedTo.join("\n") : "";
}
function inputConnectedToText(input: FrontendGraphInput): string {
return editor.inDevelopmentMode() ? input.connectedTo : "";
}
function zipWithUndefined(arr1: FrontendGraphInput[], arr2: FrontendGraphOutput[]) {
const maxLength = Math.max(arr1.length, arr2.length);
const result = [];
for (let i = 0; i < maxLength; i++) {
result.push([arr1[i], arr2[i]]);
}
return result;
}
</script>
<div class="graph" bind:this={graph} data-node-graph>
<div
class="grid-background"
style:--grid-spacing={`${gridSpacing}px`}
style:--grid-offset-x={`${$nodeGraphTransform.x}px`}
style:--grid-offset-y={`${$nodeGraphTransform.y}px`}
style:--grid-dot-radius={`${gridDotRadius}px`}
></div>
<!-- Right click menu for adding nodes -->
{#if $nodeGraph.contextMenuInformation}
<FloatingMenu
class="context-menu"
data-context-menu
styles={{
left: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates[0] * $nodeGraphTransform.scale + $nodeGraphTransform.x}px`,
top: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates[1] * $nodeGraphTransform.scale + $nodeGraphTransform.y}px`,
}}
open={true}
type="Popover"
direction="BottomLeft"
>
{#if $nodeGraph.contextMenuInformation.contextMenuData.type === "CreateNode"}
<NodeCatalog initialSearchTerm={$nodeGraph.contextMenuInformation.contextMenuData.data.compatibleType || ""} on:selectNodeType={(e) => createNode(e.detail)} />
{:else if $nodeGraph.contextMenuInformation.contextMenuData.type === "ModifyNode"}
<LayoutCol class="modify-node-menu">
<TextButton
label="Merge Selected Nodes"
action={() => {
editor.mergeSelectedNodes();
closeContextMenu();
}}
flush={true}
/>
{@const currentlyIsNode = $nodeGraph.contextMenuInformation.contextMenuData.data.currentlyIsNode}
<TextButton
label={currentlyIsNode ? "Display as Layer" : "Display as Node"}
action={() => {
if ($nodeGraph.contextMenuInformation?.contextMenuData.type === "ModifyNode") {
editor.setToNodeOrLayer($nodeGraph.contextMenuInformation.contextMenuData.data.nodeId, currentlyIsNode);
}
closeContextMenu();
}}
disabled={!$nodeGraph.contextMenuInformation.contextMenuData.data.canBeLayer}
flush={true}
/>
{#if $nodeGraph.contextMenuInformation.contextMenuData.data.hasSelectedLayers}
{@const allLocked = $nodeGraph.contextMenuInformation.contextMenuData.data.allSelectedLayersLocked}
{@const nodeId = $nodeGraph.contextMenuInformation.contextMenuData.data.nodeId}
<TextButton
label={allLocked ? "Unlock" : "Lock"}
action={() => {
if ($nodeGraph.selected.includes(nodeId)) {
editor.toggleSelectedLocked();
} else {
editor.toggleLayerLock(nodeId);
}
closeContextMenu();
}}
flush={true}
/>
{/if}
</LayoutCol>
{/if}
</FloatingMenu>
{/if}
{#if $nodeGraph.error}
<div class="node-error-container" style:transform-origin="0 0" style:transform={`translate(${$nodeGraphTransform.x}px, ${$nodeGraphTransform.y}px) scale(${$nodeGraphTransform.scale})`}>
<span class="node-error faded" style:left={`${$nodeGraph.error.position[0]}px`} style:top={`${$nodeGraph.error.position[1]}px`} transition:fade={FADE_TRANSITION}>
{$nodeGraph.error.error}
</span>
<span class="node-error hover" style:left={`${$nodeGraph.error.position[0]}px`} style:top={`${$nodeGraph.error.position[1]}px`} transition:fade={FADE_TRANSITION}>
{$nodeGraph.error.error}
</span>
</div>
{/if}
<!-- Click target debug visualizations -->
{#if $nodeGraph.clickTargets}
<div class="click-targets" style:transform-origin="0 0" style:transform={`translate(${$nodeGraphTransform.x}px, ${$nodeGraphTransform.y}px) scale(${$nodeGraphTransform.scale})`}>
<svg>
{#each $nodeGraph.clickTargets.nodeClickTargets as pathString}
<path class="node" d={pathString} />
{/each}
{#each $nodeGraph.clickTargets.layerClickTargets as pathString}
<path class="layer" d={pathString} />
{/each}
{#each $nodeGraph.clickTargets.connectorClickTargets as pathString}
<path class="connector" d={pathString} />
{/each}
{#each $nodeGraph.clickTargets.iconClickTargets as pathString}
<path class="visibility" d={pathString} />
{/each}
<path class="all-nodes-bounding-box" d={$nodeGraph.clickTargets.allNodesBoundingBox} />
{#each $nodeGraph.clickTargets.modifyImportExport as pathString}
<path class="modify-import-export" d={pathString} />
{/each}
</svg>
</div>
{/if}
<!-- Thick vertical layer connection wires -->
<div class="wires" style:transform-origin="0 0" style:transform={`translate(${$nodeGraphTransform.x}px, ${$nodeGraphTransform.y}px) scale(${$nodeGraphTransform.scale})`}>
<svg>
{#each $nodeGraphWires.values() as map}
{#each map.values() as { pathString, centerPathString, dataType, thick, dashed }}
{#if thick}
<path
d={pathString}
style:--data-line-width="8px"
style:--data-color={`var(--color-data-${dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${dataType.toLowerCase()}-dim)`}
style:--data-dasharray={`3,${dashed ? 2 : 0}`}
/>
<!-- A thin inner line splits the layer-stack wire down the middle reaching past the ends into the cleaved connector slots -->
<path d={centerPathString} style:--data-line-width="2px" style:--data-color="#444444" style:--data-color-dim="#444444" style:--data-dasharray={`3,${dashed ? 2 : 0}`} />
{/if}
{/each}
{/each}
</svg>
</div>
<!-- Import and Export connectors -->
<div class="imports-and-exports" style:transform-origin="0 0" style:transform={`translate(${$nodeGraphTransform.x}px, ${$nodeGraphTransform.y}px) scale(${$nodeGraphTransform.scale})`}>
{#if $nodeGraphImportsExports}
{#each $nodeGraphImportsExports.imports as frontendOutput, index}
{#if frontendOutput}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
class="connector"
data-connector="output"
data-tooltip-label={frontendOutput.resolvedType}
data-tooltip-description={outputConnectedToText(frontendOutput)}
data-datatype={frontendOutput.dataType}
style:--data-color={`var(--color-data-${frontendOutput.dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${frontendOutput.dataType.toLowerCase()}-dim)`}
style:--offset-left={($nodeGraphImportsExports.importPosition[0] - 8) / 24}
style:--offset-top={($nodeGraphImportsExports.importPosition[1] - 8) / 24 + index}
>
{#if frontendOutput.connectedTo.length > 0}
<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
on:pointerenter={() => (hoveringImportIndex = index)}
on:pointerleave={() => (hoveringImportIndex = undefined)}
class="edit-import-export import"
class:separator-bottom={index === 0 && $nodeGraphImportsExports.addImportExport}
class:separator-top={index === 1 && $nodeGraphImportsExports.addImportExport}
style:--offset-left={($nodeGraphImportsExports.importPosition[0] - 8) / 24}
style:--offset-top={($nodeGraphImportsExports.importPosition[1] - 8) / 24 + index}
>
{#if editingNameImportIndex === index}
<input
class="import-text-input"
type="text"
style:width={importsToEdgeTextInputWidth()}
bind:this={inputElement}
bind:value={editingNameText}
on:blur={setEditingImportName}
on:keydown={(e) => e.key === "Enter" && setEditingImportName(e)}
/>
{:else}
<p class="import-text" on:dblclick={() => setEditingImportNameIndex(index, frontendOutput.name)}>
{frontendOutput.name}
</p>
{/if}
{#if (hoveringImportIndex === index || editingNameImportIndex === index) && $nodeGraphImportsExports.addImportExport}
<IconButton
size={16}
icon="Remove"
class="remove-button-import"
data-index={index}
data-import-text-edge
action={() => {
/* Button is purely visual, clicking is handled in NodeGraphMessage::PointerDown */
}}
/>
{#if index > 0}
<div class="reorder-drag-grip" data-tooltip-description="Reorder this export"></div>
{/if}
{/if}
</div>
{:else}
<div class="plus" style:--offset-top={($nodeGraphImportsExports.importPosition[1] - 12) / 24} style:--offset-left={($nodeGraphImportsExports.importPosition[0] - 12) / 24}>
<IconButton size={24} icon="Add" action={() => editor.addPrimaryImport()} />
</div>
{/if}
{/each}
{#each $nodeGraphImportsExports.exports as frontendInput, index}
{#if frontendInput}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
class="connector"
data-connector="input"
data-tooltip-label={frontendInput.resolvedType}
data-tooltip-description={inputConnectedToText(frontendInput)}
data-datatype={frontendInput.dataType}
style:--data-color={`var(--color-data-${frontendInput.dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${frontendInput.dataType.toLowerCase()}-dim)`}
style:--offset-left={($nodeGraphImportsExports.exportPosition[0] - 8) / 24}
style:--offset-top={($nodeGraphImportsExports.exportPosition[1] - 8) / 24 + index}
>
{#if frontendInput.connectedTo !== "Connected to nothing."}
<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
on:pointerenter={() => (hoveringExportIndex = index)}
on:pointerleave={() => (hoveringExportIndex = undefined)}
class="edit-import-export export"
class:separator-bottom={index === 0 && $nodeGraphImportsExports.addImportExport}
class:separator-top={index === 1 && $nodeGraphImportsExports.addImportExport}
style:--offset-left={($nodeGraphImportsExports.exportPosition[0] - 8) / 24}
style:--offset-top={($nodeGraphImportsExports.exportPosition[1] - 8) / 24 + index}
>
{#if (hoveringExportIndex === index || editingNameExportIndex === index) && $nodeGraphImportsExports.addImportExport}
{#if index > 0}
<div class="reorder-drag-grip" data-tooltip-description="Reorder this export"></div>
{/if}
<IconButton
size={16}
icon="Remove"
class="remove-button-export"
data-index={index}
data-export-text-edge
action={() => {
/* Button is purely visual, clicking is handled in NodeGraphMessage::PointerDown */
}}
/>
{/if}
{#if editingNameExportIndex === index}
<input
type="text"
style:width={exportsToEdgeTextInputWidth()}
bind:this={inputElement}
bind:value={editingNameText}
on:blur={setEditingExportName}
on:keydown={(e) => e.key === "Enter" && setEditingExportName(e)}
/>
{:else}
<p class="export-text" on:dblclick={() => setEditingExportNameIndex(index, frontendInput.name)}>
{frontendInput.name}
</p>
{/if}
</div>
{:else}
<div class="plus" style:--offset-left={($nodeGraphImportsExports.exportPosition[0] - 12) / 24} style:--offset-top={($nodeGraphImportsExports.exportPosition[1] - 12) / 24}>
<IconButton size={24} icon="Add" action={() => editor.addPrimaryExport()} />
</div>
{/if}
{/each}
{#if $nodeGraphImportsExports.addImportExport}
<div
class="plus"
style:--offset-left={($nodeGraphImportsExports.importPosition[0] - 12) / 24}
style:--offset-top={($nodeGraphImportsExports.importPosition[1] - 12) / 24 + $nodeGraphImportsExports.imports.length}
>
<IconButton size={24} icon="Add" action={() => editor.addSecondaryImport()} />
</div>
<div
class="plus"
style:--offset-left={($nodeGraphImportsExports.exportPosition[0] - 12) / 24}
style:--offset-top={($nodeGraphImportsExports.exportPosition[1] - 12) / 24 + $nodeGraphImportsExports.exports.length}
>
<IconButton size={24} icon="Add" action={() => editor.addSecondaryExport()} />
</div>
{/if}
{#if $nodeGraph.reorderImportIndex !== undefined}
{@const position = {
x: Number($nodeGraphImportsExports.importPosition[0]),
y: Number($nodeGraphImportsExports.importPosition[1]) + Number($nodeGraph.reorderImportIndex) * 24,
}}
<div class="reorder-bar" style:--offset-left={(position.x - 48) / 24} style:--offset-top={(position.y - 12) / 24}></div>
{/if}
{#if $nodeGraph.reorderExportIndex !== undefined}
{@const position = {
x: Number($nodeGraphImportsExports.exportPosition[0]),
y: Number($nodeGraphImportsExports.exportPosition[1]) + Number($nodeGraph.reorderExportIndex) * 24,
}}
<div class="reorder-bar" style:--offset-left={position.x / 24} style:--offset-top={(position.y - 12) / 24}></div>
{/if}
{/if}
</div>
<!-- Layers and nodes -->
<div class="layers-and-nodes" style:transform-origin="0 0" style:transform={`translate(${$nodeGraphTransform.x}px, ${$nodeGraphTransform.y}px) scale(${$nodeGraphTransform.scale})`}>
<!-- Layers -->
{#each Array.from($nodeGraph.nodes)
.filter(([nodeId, node]) => node.isLayer && $visibleNodes.has(nodeId))
.map(([_, node]) => node) as node (node.id)}
{@const clipPathId = String(Math.random()).substring(2)}
{@const stackDataInput = node.exposedInputs[0]}
{@const layerAreaWidth = $nodeGraph.layerWidths.get(node.id) || 8}
{@const layerChainWidth = $nodeGraph.chainWidths.get(node.id) || 0}
{@const hasLeftInputWire = $nodeGraph.hasLeftInputWire.get(node.id) || false}
{@const description = node.reference ? $nodeGraph.nodeDescriptions.get(node.reference) : undefined}
<div
class="layer"
class:selected={$nodeGraph.selected.includes(node.id)}
class:in-selected-network={$nodeGraph.inSelectedNetwork}
class:previewed={node.previewed}
class:disabled={!node.visible}
class:locked={node.locked}
style:--offset-left={node.position?.[0] || 0}
style:--offset-top={node.position?.[1] || 0}
style:--clip-path-id={`url(#${clipPathId})`}
style:--data-color={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()}-dim)`}
style:--layer-area-width={layerAreaWidth}
style:--node-chain-area-left-extension={layerChainWidth !== 0 ? layerChainWidth + 0.5 : 0}
data-tooltip-label={nodeNameTooltipLabel(node)}
data-tooltip-description={`
${(description || "").trim()}${editor.inDevelopmentMode() ? `\n\n*ID: ${node.id}. Position: (${node.position[0]}, ${node.position[1]}).*` : ""}
`.trim()}
data-node={node.id}
>
<div class="thumbnail">
{#if $nodeGraph.thumbnails.has(node.id)}
{@html $nodeGraph.thumbnails.get(node.id)}
{/if}
<!-- Layer stacking top output -->
{#if node.primaryOutput}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 12"
class="connector top"
data-connector="output"
data-tooltip-label={node.primaryOutput.resolvedType}
data-tooltip-description={outputConnectedToText(node.primaryOutput)}
data-datatype={node.primaryOutput.dataType}
style:--data-color={`var(--color-data-${node.primaryOutput.dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${node.primaryOutput.dataType.toLowerCase()}-dim)`}
>
{#if node.primaryOutput.connectedTo.length > 0}
<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 node.primaryOutputConnectedToLayer}
<path
d="M0,4.5 C0,4.5 0,-3.5 0,-3.5 C0,-3.5 3,-3.5 3,-3.5 C3,-3.5 3,2.565 3,2.565 C2.834,2.632 2.673,2.717 2.52,2.819 C2.52,2.819 0,4.5 0,4.5 ZM5,2.566 C5,2.566 5,-3.5 5,-3.5 C5,-3.5 8,-3.5 8,-3.5 C8,-3.5 8,4.5 8,4.5 C8,4.5 5.479,2.819 5.479,2.819 C5.326,2.717 5.166,2.633 5,2.566 Z"
fill="var(--data-color-dim)"
/>
{/if}
{:else}
<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-dim)" />
{/if}
</svg>
{/if}
<!-- Layer stacking bottom input -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 12"
class="connector bottom"
data-connector="input"
data-tooltip-label={node.primaryInput ? node.primaryInput.resolvedType : ""}
data-tooltip-description={node.primaryInput ? `${validTypesText(node.primaryInput).trim()}\n\n${inputConnectedToText(node.primaryInput)}` : ""}
data-datatype={node.primaryInput?.dataType}
style:--data-color={`var(--color-data-${(node.primaryInput?.dataType || "General").toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${(node.primaryInput?.dataType || "General").toLowerCase()}-dim)`}
>
{#if node.primaryInput?.connectedTo !== "Connected to nothing."}
<path d="M0,0H8V8L5.479,6.319a2.666,2.666,0,0,0-2.959,0L0,8Z" fill="var(--data-color)" />
{#if node.primaryInputConnectedToLayer}
<path
d="M2.512,9.26 C2.673,9.157 2.834,9.072 3,9 C3,9 3,16 3,16 C3,16 0,16 0,16 C0,16 0,10.95 0,10.95 C0,10.95 2.512,9.26 2.512,9.26 ZM5,16 C5,16 5,9 5,9 C5.166,9.073 5.327,9.158 5.48,9.26 C5.48,9.26 8,10.95 8,10.95 C8,10.95 8,16 8,16 C8,16 5,16 5,16 Z"
fill="var(--data-color-dim)"
/>
{/if}
{:else}
<path d="M0,0H8V8L5.479,6.319a2.666,2.666,0,0,0-2.959,0L0,8Z" fill="var(--data-color-dim)" />
{/if}
</svg>
</div>
<!-- Layer input connector (from left) -->
{#if node.exposedInputs.length > 0}
<div class="input connectors">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
class="connector"
data-tooltip-label={stackDataInput.resolvedType}
data-tooltip-description={`${validTypesText(stackDataInput).trim()}\n\n${inputConnectedToText(stackDataInput)}`}
data-connector="input"
data-datatype={stackDataInput.dataType}
style:--data-color={`var(--color-data-${stackDataInput.dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${stackDataInput.dataType.toLowerCase()}-dim)`}
>
{#if stackDataInput.connectedTo !== undefined}
<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">
{#if editingNameNodeId === node.id}
<input
class="layer-name-input"
type="text"
bind:this={inputElement}
bind:value={editingNameText}
on:pointerdown|stopPropagation
on:dblclick|stopPropagation
on:blur={commitEditingNodeName}
on:keydown={(e) => {
// Stop propagation when we handle the key ourselves so the global keyboard forwarder (`shouldRedirectKeyboardEventToBackend`) doesn't also dispatch them.
// Its Escape carve-out would otherwise close the graph view, and Enter could trigger unrelated bindings.
if (e.key === "Enter") {
commitEditingNodeName(e);
e.stopPropagation();
} else if (e.key === "Escape") {
editingNameNodeId = undefined;
e.stopPropagation();
}
}}
/>
{:else}
<TextLabel>{node.displayName}</TextLabel>
{/if}
</div>
<div class="solo-drag-grip" data-tooltip-description="Drag only this layer without pushing others outside the stack"></div>
{#if node.locked}
<IconButton
class="lock"
data-lock-button
size={24}
icon="PadlockLocked"
hoverIcon="PadlockUnlocked"
action={() => {
/* Button is purely visual, clicking is handled in NodeGraphMessage::PointerDown */
}}
tooltipLabel="Unlock"
/>
{/if}
<IconButton
class="visibility"
data-visibility-button
size={24}
icon={node.visible ? "EyeVisible" : "EyeHidden"}
hoverIcon={node.visible ? "EyeHide" : "EyeShow"}
action={() => {
/* Button is purely visual, clicking is handled in NodeGraphMessage::PointerDown */
}}
tooltipLabel={node.visible ? "Hide" : "Show"}
/>
<svg class="border-mask" width="0" height="0">
<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(24 * layerAreaWidth - 12, layerChainWidth ? (0.5 + layerChainWidth) * 24 : 0, hasLeftInputWire)} />
</clipPath>
</defs>
</svg>
</div>
{/each}
<!-- Node connection wires -->
<div class="wires">
<svg>
{#each $nodeGraphWires.values() as map}
{#each map.values() as { pathString, dataType, thick, dashed, isList }}
{#if !thick}
{#if isList}
<!-- A rank-1 List wire reads as two parallel lines: a triple-width data line split down the middle by a 1x background-colored overlay -->
<path
d={pathString}
style:--data-line-width="4px"
style:--data-color={`var(--color-data-${dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${dataType.toLowerCase()}-dim)`}
style:--data-dasharray={`3,${dashed ? 2 : 0}`}
/>
<path d={pathString} style:--data-line-width="2px" style:--data-color="#444444" style:--data-color-dim="#444444" style:--data-dasharray={`3,${dashed ? 2 : 0}`} />
{:else}
<path
d={pathString}
style:--data-line-width="2px"
style:--data-color={`var(--color-data-${dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${dataType.toLowerCase()}-dim)`}
style:--data-dasharray={`3,${dashed ? 2 : 0}`}
/>
{/if}
{/if}
{/each}
{/each}
{#if $nodeGraph.wirePathInProgress}
<path
d={$nodeGraph.wirePathInProgress?.pathString}
style:--data-line-width={`${$nodeGraph.wirePathInProgress.thick ? 8 : 2}px`}
style:--data-color={`var(--color-data-${$nodeGraph.wirePathInProgress.dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${$nodeGraph.wirePathInProgress.dataType.toLowerCase()}-dim)`}
style:--data-dasharray={`3,${$nodeGraph.wirePathInProgress.dashed ? 2 : 0}`}
/>
{/if}
</svg>
</div>
<!-- Nodes -->
{#each Array.from($nodeGraph.nodes)
.filter(([nodeId, node]) => !node.isLayer && $visibleNodes.has(nodeId))
.map(([_, node]) => node) as node (node.id)}
{@const exposedInputsOutputs = zipWithUndefined(node.exposedInputs, node.exposedOutputs)}
{@const clipPathId = String(Math.random()).substring(2)}
{@const description = node.reference ? $nodeGraph.nodeDescriptions.get(node.reference) : undefined}
<div
class="node"
class:selected={$nodeGraph.selected.includes(node.id)}
class:previewed={node.previewed}
class:disabled={!node.visible}
style:--offset-left={node.position?.[0] || 0}
style:--offset-top={node.position?.[1] || 0}
style:--clip-path-id={`url(#${clipPathId})`}
style:--data-color={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${(node.primaryOutput?.dataType || "General").toLowerCase()}-dim)`}
data-tooltip-label={nodeNameTooltipLabel(node)}
data-tooltip-description={`
${(description || "").trim()}${editor.inDevelopmentMode() ? `\n\n*ID: ${node.id}. Position: (${node.position[0]}, ${node.position[1]}).*` : ""}
`.trim()}
data-node={node.id}
>
<!-- Primary row -->
<div class="primary" class:in-selected-network={$nodeGraph.inSelectedNetwork} class:no-secondary-section={exposedInputsOutputs.length === 0}>
<!-- TODO: Allow the user to edit the name, just like in the Layers panel -->
<TextLabel>{node.displayName}</TextLabel>
</div>
<!-- Secondary rows -->
{#if exposedInputsOutputs.length > 0}
<div class="secondary" class:in-selected-network={$nodeGraph.inSelectedNetwork}>
{#each exposedInputsOutputs as [input, output]}
<div class={`secondary-row expanded ${input !== undefined ? "input" : "output"}`}>
<TextLabel tooltipLabel={input !== undefined ? input.name : output.name} tooltipDescription={input !== undefined ? input.description : output.description}>
{input !== undefined ? input.name : output.name}
</TextLabel>
</div>
{/each}
</div>
{/if}
<!-- Input connectors -->
<div class="input connectors">
{#if node.primaryInput?.dataType}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
class="connector primary-connector"
data-connector="input"
data-tooltip-label={node.primaryInput.resolvedType}
data-tooltip-description={`${validTypesText(node.primaryInput).trim()}\n\n${inputConnectedToText(node.primaryInput)}`}
data-datatype={node.primaryInput?.dataType}
style:--data-color={`var(--color-data-${node.primaryInput.dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${node.primaryInput.dataType.toLowerCase()}-dim)`}
>
{#if node.primaryInput.connectedTo !== undefined}
<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>
{/if}
{#each node.exposedInputs as secondary, index}
{#if index < node.exposedInputs.length}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
class="connector"
data-connector="input"
data-tooltip-label={secondary.resolvedType}
data-tooltip-description={`${validTypesText(secondary).trim()}\n\n${inputConnectedToText(secondary)}`}
data-datatype={secondary.dataType}
style:--data-color={`var(--color-data-${secondary.dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${secondary.dataType.toLowerCase()}-dim)`}
>
{#if secondary.connectedTo !== undefined}
<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>
{/if}
{/each}
</div>
<!-- Output connectors -->
<div class="output connectors">
{#if node.primaryOutput}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
class="connector primary-connector"
data-connector="output"
data-tooltip-label={node.primaryOutput.resolvedType}
data-tooltip-description={`${outputConnectedToText(node.primaryOutput)}`}
data-datatype={node.primaryOutput.dataType}
style:--data-color={`var(--color-data-${node.primaryOutput.dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${node.primaryOutput.dataType.toLowerCase()}-dim)`}
>
{#if node.primaryOutput.connectedTo !== undefined}
<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>
{/if}
{#each node.exposedOutputs as secondary}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
class="connector"
data-connector="output"
data-tooltip-label={secondary.resolvedType}
data-tooltip-description={`${outputConnectedToText(secondary)}`}
data-datatype={secondary.dataType}
style:--data-color={`var(--color-data-${secondary.dataType.toLowerCase()})`}
style:--data-color-dim={`var(--color-data-${secondary.dataType.toLowerCase()}-dim)`}
>
{#if secondary.connectedTo !== undefined}
<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>
{/each}
</div>
<svg class="border-mask" width="0" height="0">
<defs>
<clipPath id={clipPathId}>
<path
clip-rule="evenodd"
d={nodeBorderMask(120, node.primaryInput?.dataType !== undefined, node.exposedInputs.length, node.primaryOutput !== undefined, node.exposedOutputs.length)}
/>
</clipPath>
</defs>
</svg>
</div>
{/each}
</div>
</div>
<!-- Box selection widget -->
{#if $nodeGraph.box}
<div
class="box-selection"
style:left={`${Math.min($nodeGraph.box.startX, $nodeGraph.box.endX)}px`}
style:top={`${Math.min($nodeGraph.box.startY, $nodeGraph.box.endY)}px`}
style:width={`${Math.abs($nodeGraph.box.startX - $nodeGraph.box.endX)}px`}
style:height={`${Math.abs($nodeGraph.box.startY - $nodeGraph.box.endY)}px`}
></div>
{/if}
<style lang="scss">
.graph {
position: relative;
overflow: hidden;
display: flex;
flex-direction: row;
flex-grow: 1;
.grid-background {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
// We're displaying the dotted grid in a pseudo-element because `image-rendering` is an inherited property and we don't want it to apply to child elements
&::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
background-size: var(--grid-spacing) var(--grid-spacing);
background-position: calc(var(--grid-offset-x) - var(--grid-dot-radius)) calc(var(--grid-offset-y) - var(--grid-dot-radius));
background-image: radial-gradient(circle at var(--grid-dot-radius) var(--grid-dot-radius), rgb(from var(--color-f-white) r g b / 0.2) var(--grid-dot-radius), transparent 0);
background-repeat: repeat;
image-rendering: pixelated;
}
}
> img {
position: absolute;
bottom: 0;
}
.breadcrumb-trail-buttons {
margin-top: 8px;
margin-left: 8px;
}
.context-menu {
width: max-content;
.modify-node-menu {
margin: -4px;
.text-button {
justify-content: left;
}
}
.tail {
display: none;
}
}
.node-error-container {
position: absolute;
z-index: 1;
.node-error {
position: absolute;
width: max-content;
white-space: pre-wrap;
max-width: 600px;
line-height: 18px;
color: var(--color-2-mildblack);
background: var(--color-error-red);
padding: 8px;
border-radius: 4px;
transition: opacity 0.2s;
opacity: 0.5;
transform: translateY(-100%);
// Tail
&::after {
content: "";
position: absolute;
left: 6px;
bottom: -8px;
width: 0;
height: 0;
border-style: solid;
border-width: 8px 6px 0 6px;
border-color: var(--color-error-red) transparent transparent transparent;
}
&.hover {
opacity: 0;
z-index: 1;
pointer-events: none;
}
&.faded:hover + .hover {
opacity: 1;
}
&.faded:hover {
z-index: 2;
opacity: 1;
user-select: text;
transition:
opacity 0.2s,
z-index 0s 0.2s;
&::selection {
background-color: var(--color-e-nearwhite);
// Target only Safari
@supports (background: -webkit-named-image(i)) {
& {
// Setting an alpha value opts out of Safari's "fancy" (but not visible on dark backgrounds) selection highlight rendering
// https://stackoverflow.com/a/71753552/775283
background-color: rgb(from var(--color-e-nearwhite) r g b / calc(254 / 255));
}
}
}
}
}
}
.click-targets {
position: absolute;
pointer-events: none;
width: 100%;
height: 100%;
z-index: 10;
svg {
overflow: visible;
width: 100%;
height: 100%;
stroke-width: 1;
fill: none;
.layer {
stroke: yellow;
}
.node {
stroke: blue;
}
.connector {
stroke: green;
}
.visibility {
stroke: red;
}
.all-nodes-bounding-box {
stroke: purple;
}
.modify-import-export {
stroke: orange;
}
}
}
.wires {
pointer-events: none;
position: absolute;
width: 100%;
height: 100%;
svg {
width: 100%;
height: 100%;
overflow: visible;
path {
fill: none;
stroke: var(--data-color-dim);
stroke-width: var(--data-line-width);
stroke-dasharray: var(--data-dasharray);
}
}
}
.imports-and-exports {
width: 100%;
height: 100%;
position: absolute;
pointer-events: none;
// Keeps the connectors above the wires
z-index: 1;
// Zero specificity with `:where()` to allow other rules to override `pointer-events`
:where(.graph-view.open .graph .imports-and-exports > *) {
pointer-events: auto;
}
.connector {
position: absolute;
width: 8px;
height: 8px;
margin-top: 4px;
margin-left: 5px;
top: calc(var(--offset-top) * 24px);
left: calc(var(--offset-left) * 24px);
}
.reorder-bar {
position: absolute;
top: calc(var(--offset-top) * 24px);
left: calc(var(--offset-left) * 24px);
width: 50px;
height: 2px;
background: white;
}
.plus {
position: absolute;
top: calc(var(--offset-top) * 24px);
left: calc(var(--offset-left) * 24px);
}
.edit-import-export {
position: absolute;
display: flex;
align-items: center;
top: calc(var(--offset-top) * 24px);
margin-top: -5px;
height: 24px;
&.separator-bottom::after,
&.separator-top::before {
content: "";
position: absolute;
background: var(--color-8-uppergray);
height: 1px;
left: -4px;
right: -4px;
}
&.separator-bottom::after {
bottom: -1px;
}
&.separator-top::before {
top: 0;
}
&.import {
right: calc(100% - var(--offset-left) * 24px);
}
&.export {
left: calc(var(--offset-left) * 24px + 17px);
}
.import-text {
text-align: right;
text-wrap: nowrap;
}
.export-text {
text-wrap: nowrap;
}
.import-text-input {
text-align: right;
}
.remove-button-import {
margin-left: 3px;
}
.remove-button-export {
margin-right: 3px;
}
.reorder-drag-grip {
width: 8px;
height: 24px;
background-position: 2px 8px;
border-radius: 2px;
margin: -6px 0;
background-image: var(--icon-drag-grip-hover);
}
}
}
.layers-and-nodes {
position: absolute;
pointer-events: none;
width: 100%;
height: 100%;
// Zero specificity with `:where()` to allow other rules to override `pointer-events`
:where(.graph-view.open .graph .layers-and-nodes > *) {
pointer-events: auto;
}
}
.layer,
.node {
position: absolute;
display: flex;
left: calc(var(--offset-left) * 24px);
top: calc(var(--offset-top) * 24px);
// TODO: Reenable the `transition` property below after dealing with all edge cases where the wires need to be updated until the transition is complete
// transition: top 0.1s cubic-bezier(0, 0, 0.2, 1), left 0.1s cubic-bezier(0, 0, 0.2, 1); // Update `DRAG_SMOOTHING_TIME` in the JS above.
// TODO: Reenable the `backdrop-filter` property once a solution can be found for the black whole-page flickering problems it causes in Chrome.
// TODO: Additionally, find a solution for this having no effect in Firefox due to a browser bug caused when the two
// ancestor elements, `.graph` and `.panel`, each have the simultaneous pairing of `overflow: hidden` and `border-radius`.
// See: https://stackoverflow.com/questions/75137879/bug-with-backdrop-filter-in-firefox
// backdrop-filter: blur(4px);
background: rgb(from var(--color-0-black) r g b / 0.33);
&::after {
content: "";
position: absolute;
box-sizing: border-box;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
clip-path: var(--clip-path-id);
}
.border-mask {
position: absolute;
top: 0;
}
&.disabled {
background: rgb(from var(--color-4-dimgray) r g b / 0.33);
color: var(--color-a-softgray);
.icon-label {
fill: var(--color-a-softgray);
}
}
&.previewed::after {
border: 1px dashed var(--data-color);
}
.connectors {
position: absolute;
// Keeps the connectors above the wires
z-index: 1;
&.input {
left: -3px;
}
&.output {
right: -5px;
}
}
.connector {
// Double the intended value because of margin collapsing, but for the first and last we divide it by two as intended
margin: calc(24px - 8px) 0;
width: 8px;
height: 8px;
}
.text-label {
overflow: hidden;
text-overflow: ellipsis;
}
}
.layer {
border-radius: 8px;
--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, as well as the `left` connector offset CSS rule above in `.connectors.input` above.
width: calc((var(--layer-area-width) - 0.5) * 24px);
padding-left: calc(var(--node-chain-area-left-extension) * 24px);
margin-left: calc((0.5 - var(--node-chain-area-left-extension)) * 24px);
&::after {
border: 1px solid var(--color-5-dullgray);
border-radius: 8px;
}
&.selected {
background: rgb(from var(--color-5-dullgray) r g b / 0.33);
&.in-selected-network {
background: rgb(from var(--color-6-lowergray) r g b / 0.33);
}
}
.thumbnail {
background: var(--color-2-mildblack);
border: 1px solid var(--data-color-dim);
border-radius: 2px;
position: relative;
box-sizing: border-box;
height: 48px;
// We shorten the width by 1px on the left and right so the inner thumbnail graphic maintains a perfect 3:2 aspect ratio
width: calc(72px - 2px);
margin: 0 1px;
&::before {
content: "";
background-image: var(--color-transparent-checkered-background);
background-size: var(--color-transparent-checkered-background-size);
background-position: var(--color-transparent-checkered-background-position);
background-repeat: var(--color-transparent-checkered-background-repeat);
}
&::before,
svg:not(.connector) {
pointer-events: none;
position: absolute;
margin: auto;
top: 1px;
left: 1px;
width: calc(100% - 2px);
height: calc(100% - 2px);
}
.connector {
position: absolute;
margin: 0 auto;
left: 0;
right: 0;
height: 12px;
&.top {
top: -13px;
}
&.bottom {
bottom: -13px;
}
}
}
.details {
display: flex;
align-items: center;
margin: 0 8px;
.text-label {
white-space: nowrap;
line-height: 48px;
}
.layer-name-input {
color: inherit;
background: var(--color-1-nearblack);
border: none;
outline: none;
margin: 0 -4px;
padding: 0 4px;
height: 24px;
border-radius: 2px;
field-sizing: content;
// Stack above the absolutely-positioned grip/lock/visibility siblings, which can otherwise overlap the input's right edge and hijack clicks there.
position: relative;
z-index: 1;
}
}
.solo-drag-grip {
width: 8px;
height: 24px;
background-position: 2px 8px;
right: calc(-12px + 24px);
border-radius: 2px;
}
&.locked .solo-drag-grip {
right: calc(-12px + 24px + 24px);
}
.solo-drag-grip:hover,
&.selected .solo-drag-grip {
background-image: var(--icon-drag-grip);
&:hover {
background-image: var(--icon-drag-grip-hover);
}
}
.visibility {
right: -12px;
}
.lock {
right: 12px;
}
.input.connectors {
left: calc(-3px + var(--node-chain-area-left-extension) * 24px - 36px);
}
.solo-drag-grip,
.lock,
.visibility,
.input.connectors,
.input.connectors .connector {
position: absolute;
margin: auto 0;
top: 0;
bottom: 0;
}
.input.connectors .connector {
left: 24px;
}
}
.node {
flex-direction: column;
border-radius: 2px;
width: 120px;
top: calc((var(--offset-top) + 0.5) * 24px);
&::after {
border: 1px solid var(--data-color-dim);
border-radius: 2px;
}
&.selected {
.primary {
background: rgb(from var(--color-f-white) r g b / 0.15);
&.in-selected-network {
background: rgb(from var(--color-f-white) r g b / 0.2);
}
}
.secondary {
background: rgb(from var(--color-f-white) r g b / 0.1);
&.in-selected-network {
background: rgb(from var(--color-f-white) r g b / 0.15);
}
}
}
.connector {
&:first-of-type {
margin-top: calc((24px - 8px) / 2);
&:not(.primary-connector) {
margin-top: calc((24px - 8px) / 2 + 24px);
}
}
&:last-of-type {
margin-bottom: calc((24px - 8px) / 2);
}
}
.primary {
display: flex;
align-items: center;
position: relative;
width: 100%;
height: 24px;
border-radius: 2px 2px 0 0;
background: rgb(from var(--color-f-white) r g b / 0.05);
&.no-secondary-section {
border-radius: 2px;
}
.icon-label {
display: none; // Remove after we have unique icons for the nodes
margin: 0 8px;
}
.text-label {
// margin-right: 4px; // Restore after reenabling icon-label
margin: 0 8px;
}
}
.secondary {
display: flex;
flex-direction: column;
width: 100%;
position: relative;
.secondary-row {
position: relative;
display: flex;
align-items: center;
margin: 0 8px;
width: calc(100% - 8px - 8px);
height: 24px;
&:last-of-type {
border-radius: 0 0 2px 2px;
}
.text-label {
width: 100%;
}
&.output {
flex-direction: row-reverse;
text-align: right;
svg {
width: 30px;
height: 20px;
}
}
}
&::before {
left: 0;
}
&::after {
right: 0;
}
}
}
}
.box-selection {
position: absolute;
pointer-events: none;
z-index: 2;
// TODO: This will be removed after box selection, and all of graph rendering, is moved to the backend and this whole file
// is removed, but for now this color needs to stay in sync with `COLOR_OVERLAY_BLUE` set in consts.rs of the editor backend.
background: rgba(0, 168, 255, 0.05);
border: 1px solid #00a8ff;
}
</style>