| <script lang="ts"> |
| import { getContext } from "svelte"; |
| import { cubicInOut } from "svelte/easing"; |
| import { fade } from "svelte/transition"; |
| |
| import type { Editor } from "@graphite/editor"; |
| import type { Node } from "@graphite/messages"; |
| import type { FrontendNode, FrontendGraphInput, FrontendGraphOutput } from "@graphite/messages"; |
| import type { NodeGraphState } from "@graphite/state-providers/node-graph"; |
| import type { IconName } from "@graphite/utility-functions/icons"; |
| |
| 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 IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte"; |
| import Separator from "@graphite/components/widgets/labels/Separator.svelte"; |
| import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte"; |
| |
| const GRID_COLLAPSE_SPACING = 10; |
| const GRID_SIZE = 24; |
| const FADE_TRANSITION = { duration: 200, easing: cubicInOut }; |
| |
| const editor = getContext<Editor>("editor"); |
| const nodeGraph = getContext<NodeGraphState>("nodeGraph"); |
| |
| let graph: HTMLDivElement | undefined; |
| |
| |
| |
| |
| $: gridSpacing = calculateGridSpacing($nodeGraph.transform.scale); |
| $: dotRadius = 1 + Math.floor($nodeGraph.transform.scale - 0.5 + 0.001) / 2; |
| |
| let inputElement: HTMLInputElement; |
| let hoveringImportIndex: number | undefined = undefined; |
| let hoveringExportIndex: number | undefined = undefined; |
| |
| let editingNameImportIndex: number | undefined = undefined; |
| let editingNameExportIndex: number | 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; |
| } |
| |
| function focusInput(currentName: string) { |
| editingNameText = currentName; |
| setTimeout(() => { |
| if (inputElement) { |
| inputElement.focus(); |
| } |
| }, 0); |
| } |
| |
| function setEditingImportName(event: Event) { |
| if (editingNameImportIndex !== undefined) { |
| let text = (event.target as HTMLInputElement)?.value; |
| editor.handle.setImportName(editingNameImportIndex, text); |
| editingNameImportIndex = undefined; |
| } |
| } |
| |
| function setEditingExportName(event: Event) { |
| if (editingNameExportIndex !== undefined) { |
| let text = (event.target as HTMLInputElement)?.value; |
| editor.handle.setExportName(editingNameExportIndex, text); |
| editingNameExportIndex = undefined; |
| } |
| } |
| |
| 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 nodeIcon(icon?: string): IconName { |
| if (!icon) return "NodeNodes"; |
| const iconMap: Record<string, IconName> = { |
| Output: "NodeOutput", |
| }; |
| return iconMap[icon] || "NodeNodes"; |
| } |
| |
| function toggleLayerDisplay(displayAsLayer: boolean, toggleId: bigint) { |
| let node = $nodeGraph.nodes.get(toggleId); |
| if (node) editor.handle.setToNodeOrLayer(node.id, displayAsLayer); |
| } |
| |
| function canBeToggledBetweenNodeAndLayer(toggleDisplayAsLayerNodeId: bigint) { |
| return $nodeGraph.nodes.get(toggleDisplayAsLayerNodeId)?.canBeLayer || false; |
| } |
| |
| function createNode(nodeType: string) { |
| if ($nodeGraph.contextMenuInformation === undefined) return; |
| |
| editor.handle.createNode(nodeType, $nodeGraph.contextMenuInformation.contextMenuCoordinates.x, $nodeGraph.contextMenuInformation.contextMenuCoordinates.y); |
| } |
| |
| 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 }[] = []; |
| |
| |
| if (primaryInputExists) boxes.push({ x: -8, y: 4, width: 16, height: 16 }); |
| |
| for (let i = 0; i < exposedSecondaryInputs; i++) boxes.push({ x: -8, y: 4 + (i + 1) * 24, width: 16, height: 16 }); |
| |
| |
| if (primaryOutputExists) boxes.push({ x: nodeWidth - 8, y: 4, width: 16, height: 16 }); |
| |
| 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 }[] = []; |
| |
| |
| if (hasLeftInputWire && nodeChainAreaLeftExtension > 0) { |
| boxes.push({ x: -8, y: 16, width: 16, height: 16 }); |
| } |
| |
| |
| boxes.push({ x: nodeChainAreaLeftExtension - 8, y: -FUDGE_HEIGHT_BEYOND_LAYER_HEIGHT, width: THUMBNAIL_WIDTH, height: NODE_HEIGHT + FUDGE_HEIGHT_BEYOND_LAYER_HEIGHT * 2 }); |
| |
| |
| 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 dataTypeTooltip(value: FrontendGraphInput | FrontendGraphOutput): string { |
| return value.resolvedType ? `Data Type:\n${value.resolvedType}` : `Data Type (Unresolved):\n${value.dataType}`; |
| } |
| |
| 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 { |
| if (output.connectedTo.length === 0) return "Connected to nothing"; |
| |
| return output.connectedTo |
| .map((inputConnector) => { |
| if ((inputConnector as Node).nodeId === undefined) return `Connected to export index ${inputConnector.index}`; |
| return `Connected to ${(inputConnector as Node).nodeId}, port index ${inputConnector.index}`; |
| }) |
| .join("\n"); |
| } |
| |
| function inputConnectedToText(input: FrontendGraphInput): string { |
| if (input.connectedTo === undefined) return "Connected to nothing"; |
| if ((input.connectedTo as Node).nodeId === undefined) return `Connected to import index ${input.connectedTo.index}`; |
| return `Connected to ${(input.connectedTo as Node).nodeId}, port index ${input.connectedTo.index}`; |
| } |
| |
| function primaryOutputConnectedToLayer(node: FrontendNode): boolean { |
| let firstConnectedNode = Array.from($nodeGraph.nodes.values()).find((n) => |
| node.primaryOutput?.connectedTo.some((connector) => { |
| if ((connector as Node).nodeId === undefined) return false; |
| if (connector.index !== 0n) return false; |
| return n.id === (connector as Node).nodeId || false; |
| }), |
| ); |
| return firstConnectedNode?.isLayer || false; |
| } |
| |
| function primaryInputConnectedToLayer(node: FrontendNode): boolean { |
| const connectedNode = Array.from($nodeGraph.nodes.values()).find((n) => { |
| if ((node.primaryInput?.connectedTo as Node) === undefined) return false; |
| return n.id === (node.primaryInput?.connectedTo as Node).nodeId; |
| }); |
| return connectedNode?.isLayer || false; |
| } |
| |
| 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} |
| style:--grid-spacing={`${gridSpacing}px`} |
| style:--grid-offset-x={`${$nodeGraph.transform.x}px`} |
| style:--grid-offset-y={`${$nodeGraph.transform.y}px`} |
| style:--dot-radius={`${dotRadius}px`} |
| data-node-graph |
| > |
| |
| {#if $nodeGraph.contextMenuInformation} |
| <LayoutCol |
| class="context-menu" |
| data-context-menu |
| styles={{ |
| left: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates.x * $nodeGraph.transform.scale + $nodeGraph.transform.x}px`, |
| top: `${$nodeGraph.contextMenuInformation.contextMenuCoordinates.y * $nodeGraph.transform.scale + $nodeGraph.transform.y}px`, |
| }} |
| > |
| {#if typeof $nodeGraph.contextMenuInformation.contextMenuData === "string" && $nodeGraph.contextMenuInformation.contextMenuData === "CreateNode"} |
| <NodeCatalog on:selectNodeType={(e) => createNode(e.detail)} /> |
| {:else if $nodeGraph.contextMenuInformation.contextMenuData && "compatibleType" in $nodeGraph.contextMenuInformation.contextMenuData} |
| <NodeCatalog initialSearchTerm={$nodeGraph.contextMenuInformation.contextMenuData.compatibleType || ""} on:selectNodeType={(e) => createNode(e.detail)} /> |
| {:else} |
| {@const contextMenuData = $nodeGraph.contextMenuInformation.contextMenuData} |
| <LayoutRow class="toggle-layer-or-node"> |
| <TextLabel>Display as</TextLabel> |
| <RadioInput |
| selectedIndex={contextMenuData.currentlyIsNode ? 0 : 1} |
| entries={[ |
| { |
| value: "node", |
| label: "Node", |
| action: () => { |
| toggleLayerDisplay(false, contextMenuData.nodeId); |
| }, |
| }, |
| { |
| value: "layer", |
| label: "Layer", |
| action: () => { |
| toggleLayerDisplay(true, contextMenuData.nodeId); |
| }, |
| }, |
| ]} |
| disabled={!canBeToggledBetweenNodeAndLayer(contextMenuData.nodeId)} |
| /> |
| </LayoutRow> |
| <Separator type="Section" direction="Vertical" /> |
| <LayoutRow class="merge-selected-nodes"> |
| <TextButton label="Merge Selected Nodes" action={() => editor.handle.mergeSelectedNodes()} /> |
| </LayoutRow> |
| {/if} |
| </LayoutCol> |
| {/if} |
| |
| |
| {#if $nodeGraph.clickTargets} |
| <div class="click-targets" style:transform-origin={`0 0`} style:transform={`translate(${$nodeGraph.transform.x}px, ${$nodeGraph.transform.y}px) scale(${$nodeGraph.transform.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.portClickTargets as pathString} |
| <path class="port" 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} /> |
| <path class="all-nodes-bounding-box" d={$nodeGraph.clickTargets.importExportsBoundingBox} /> |
| {#each $nodeGraph.clickTargets.modifyImportExport as pathString} |
| <path class="modify-import-export" d={pathString} /> |
| {/each} |
| </svg> |
| </div> |
| {/if} |
| |
| |
| <div class="wires" style:transform-origin={`0 0`} style:transform={`translate(${$nodeGraph.transform.x}px, ${$nodeGraph.transform.y}px) scale(${$nodeGraph.transform.scale})`}> |
| <svg> |
| {#each $nodeGraph.wires.values() as map} |
| {#each map.values() as { pathString, 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}`} |
| /> |
| {/if} |
| {/each} |
| {/each} |
| </svg> |
| </div> |
| |
| |
| <div class="imports-and-exports" style:transform-origin={`0 0`} style:transform={`translate(${$nodeGraph.transform.x}px, ${$nodeGraph.transform.y}px) scale(${$nodeGraph.transform.scale})`}> |
| {#each $nodeGraph.imports as { outputMetadata, position }, index} |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 8 8" |
| class="port" |
| data-port="output" |
| data-datatype={outputMetadata.dataType} |
| style:--data-color={`var(--color-data-${outputMetadata.dataType.toLowerCase()})`} |
| style:--data-color-dim={`var(--color-data-${outputMetadata.dataType.toLowerCase()}-dim)`} |
| style:--offset-left={position.x / 24} |
| style:--offset-top={position.y / 24} |
| > |
| <title>{`${dataTypeTooltip(outputMetadata)}\n\n${outputConnectedToText(outputMetadata)}`}</title> |
| {#if outputMetadata.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 |
| class="edit-import-export import" |
| on:pointerenter={() => (hoveringImportIndex = index)} |
| on:pointerleave={() => (hoveringImportIndex = undefined)} |
| style:--offset-left={position.x / 24} |
| style:--offset-top={position.y / 24} |
| > |
| {#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, outputMetadata.name)}>{outputMetadata.name}</p> |
| {/if} |
| {#if hoveringImportIndex === index || editingNameImportIndex === index} |
| <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 */ |
| }} |
| /> |
| <div class="reorder-drag-grip" title="Reorder this import"></div> |
| {/if} |
| </div> |
| {/each} |
| {#if $nodeGraph.reorderImportIndex !== undefined} |
| {@const position = { |
| x: Number($nodeGraph.imports[0].position.x), |
| y: Number($nodeGraph.imports[0].position.y) + Number($nodeGraph.reorderImportIndex) * 24, |
| }} |
| <div class="reorder-bar" style:--offset-left={(position.x - 48) / 24} style:--offset-top={(position.y - 4) / 24} /> |
| {/if} |
| {#if $nodeGraph.addImport !== undefined} |
| <div class="plus" style:--offset-left={$nodeGraph.addImport.x / 24} style:--offset-top={$nodeGraph.addImport.y / 24}> |
| <IconButton |
| size={24} |
| icon="Add" |
| action={() => { |
| /* Button is purely visual, clicking is handled in NodeGraphMessage::PointerDown */ |
| }} |
| /> |
| </div> |
| {/if} |
| {#each $nodeGraph.exports as { inputMetadata, position }, index} |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 8 8" |
| class="port" |
| data-port="input" |
| data-datatype={inputMetadata.dataType} |
| style:--data-color={`var(--color-data-${inputMetadata.dataType.toLowerCase()})`} |
| style:--data-color-dim={`var(--color-data-${inputMetadata.dataType.toLowerCase()}-dim)`} |
| style:--offset-left={position.x / 24} |
| style:--offset-top={position.y / 24} |
| > |
| <title>{`${dataTypeTooltip(inputMetadata)}\n\n${inputConnectedToText(inputMetadata)}`}</title> |
| {#if inputMetadata.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 |
| class="edit-import-export export" |
| on:pointerenter={() => (hoveringExportIndex = index)} |
| on:pointerleave={() => (hoveringExportIndex = undefined)} |
| style:--offset-left={position.x / 24} |
| style:--offset-top={position.y / 24} |
| > |
| {#if hoveringExportIndex === index || editingNameExportIndex === index} |
| <div class="reorder-drag-grip" title="Reorder this export"></div> |
| <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, inputMetadata.name)}>{inputMetadata.name}</p> |
| {/if} |
| </div> |
| {/each} |
| {#if $nodeGraph.reorderExportIndex !== undefined} |
| {@const position = { |
| x: Number($nodeGraph.exports[0].position.x), |
| y: Number($nodeGraph.exports[0].position.y) + Number($nodeGraph.reorderExportIndex) * 24, |
| }} |
| <div class="reorder-bar" style:--offset-left={position.x / 24} style:--offset-top={(position.y - 4) / 24} /> |
| {/if} |
| {#if $nodeGraph.addExport !== undefined} |
| <div class="plus" style:--offset-left={$nodeGraph.addExport.x / 24} style:--offset-top={$nodeGraph.addExport.y / 24}> |
| <IconButton |
| size={24} |
| icon={"Add"} |
| action={() => { |
| /* Button is purely visual, clicking is handled in NodeGraphMessage::PointerDown */ |
| }} |
| /> |
| </div> |
| {/if} |
| </div> |
| |
| |
| <div class="layers-and-nodes" style:transform-origin={`0 0`} style:transform={`translate(${$nodeGraph.transform.x}px, ${$nodeGraph.transform.y}px) scale(${$nodeGraph.transform.scale})`}> |
| |
| {#each Array.from($nodeGraph.nodes) |
| .filter(([nodeId, node]) => node.isLayer && $nodeGraph.visibleNodes.has(nodeId)) |
| .map(([_, node], nodeIndex) => ({ node, nodeIndex })) as { node, nodeIndex } (nodeIndex)} |
| {@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} |
| style:--offset-left={node.position?.x || 0} |
| style:--offset-top={node.position?.y || 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} |
| title={`${node.displayName}\n\n${description || ""}`.trim() + (editor.handle.inDevelopmentMode() ? `\n\nNode ID: ${node.id}` : "")} |
| data-node={node.id} |
| > |
| {#if node.errors} |
| <span class="node-error faded" transition:fade={FADE_TRANSITION} title="" data-node-error>{node.errors}</span> |
| <span class="node-error hover" transition:fade={FADE_TRANSITION} title="" data-node-error>{node.errors}</span> |
| {/if} |
| <div class="thumbnail"> |
| {#if $nodeGraph.thumbnails.has(node.id)} |
| {@html $nodeGraph.thumbnails.get(node.id)} |
| {/if} |
| |
| {#if node.primaryOutput} |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 8 12" |
| class="port top" |
| data-port="output" |
| 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)`} |
| > |
| <title>{`${dataTypeTooltip(node.primaryOutput)}\n\n${outputConnectedToText(node.primaryOutput)}`}</title> |
| {#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 primaryOutputConnectedToLayer(node)} |
| <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} |
| <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} |
| |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 8 12" |
| class="port bottom" |
| data-port="input" |
| 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} |
| <title>{`${dataTypeTooltip(node.primaryInput)}\n\n${validTypesText(node.primaryInput)}\n\n${inputConnectedToText(node.primaryInput)}`}</title> |
| {/if} |
| {#if node.primaryInput?.connectedTo !== undefined} |
| <path d="M0,0H8V8L5.479,6.319a2.666,2.666,0,0,0-2.959,0L0,8Z" fill="var(--data-color)" /> |
| {#if primaryInputConnectedToLayer(node)} |
| <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} |
| <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> |
| |
| {#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.toLowerCase()})`} |
| style:--data-color-dim={`var(--color-data-${stackDataInput.dataType.toLowerCase()}-dim)`} |
| > |
| <title>{`${dataTypeTooltip(stackDataInput)}\n\n${validTypesText(stackDataInput)}\n\n${inputConnectedToText(stackDataInput)}`}</title> |
| {#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"> |
| |
| <TextLabel>{node.displayName}</TextLabel> |
| </div> |
| <div class="solo-drag-grip" title="Drag only this layer without pushing others outside the stack"></div> |
| <IconButton |
| class={"visibility"} |
| data-visibility-button |
| size={24} |
| icon={node.visible ? "EyeVisible" : "EyeHidden"} |
| action={() => { |
| /* Button is purely visual, clicking is handled in NodeGraphMessage::PointerDown */ |
| }} |
| tooltip={node.visible ? "Visible" : "Hidden"} |
| /> |
| |
| <svg class="border-mask" width="0" height="0"> |
| <defs> |
| <clipPath id={clipPathId}> |
| { width: ... } |
| <path clip-rule="evenodd" d={layerBorderMask(24 * layerAreaWidth - 12, layerChainWidth ? (0.5 + layerChainWidth) * 24 : 0, hasLeftInputWire)} /> |
| </clipPath> |
| </defs> |
| </svg> |
| </div> |
| {/each} |
| |
| |
| <div class="wires"> |
| <svg> |
| {#each $nodeGraph.wires.values() as map} |
| {#each map.values() as { pathString, dataType, thick, dashed }} |
| {#if !thick} |
| <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} |
| {/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> |
| |
| |
| {#each Array.from($nodeGraph.nodes) |
| .filter(([nodeId, node]) => !node.isLayer && $nodeGraph.visibleNodes.has(nodeId)) |
| .map(([_, node], nodeIndex) => ({ node, nodeIndex })) as { node, nodeIndex } (nodeIndex)} |
| {@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?.x || 0} |
| style:--offset-top={node.position?.y || 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)`} |
| title={`${node.displayName}\n\n${description || ""}`.trim() + (editor.handle.inDevelopmentMode() ? `\n\nNode ID: ${node.id}` : "")} |
| data-node={node.id} |
| > |
| {#if node.errors} |
| <span class="node-error faded" transition:fade={FADE_TRANSITION} title="" data-node-error>{node.errors}</span> |
| <span class="node-error hover" transition:fade={FADE_TRANSITION} title="" data-node-error>{node.errors}</span> |
| {/if} |
| |
| <div class="primary" class:in-selected-network={$nodeGraph.inSelectedNetwork} class:no-secondary-section={exposedInputsOutputs.length === 0}> |
| <IconLabel icon={nodeIcon(node.reference)} /> |
| |
| <TextLabel>{node.displayName}</TextLabel> |
| </div> |
| |
| {#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 tooltip={(input !== undefined ? `${input.name}\n\n${input.description}` : `${output.name}\n\n${output.description}`).trim()}> |
| {input !== undefined ? input.name : output.name} |
| </TextLabel> |
| </div> |
| {/each} |
| </div> |
| {/if} |
| |
| <div class="input ports"> |
| {#if node.primaryInput?.dataType} |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 8 8" |
| class="port primary-port" |
| data-port="input" |
| 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)`} |
| > |
| <title>{`${dataTypeTooltip(node.primaryInput)}\n\n${validTypesText(node.primaryInput)}\n\n${inputConnectedToText(node.primaryInput)}`}</title> |
| {#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="port" |
| data-port="input" |
| data-datatype={secondary.dataType} |
| style:--data-color={`var(--color-data-${secondary.dataType.toLowerCase()})`} |
| style:--data-color-dim={`var(--color-data-${secondary.dataType.toLowerCase()}-dim)`} |
| > |
| <title>{`${dataTypeTooltip(secondary)}\n\n${validTypesText(secondary)}\n\n${inputConnectedToText(secondary)}`}</title> |
| {#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> |
| |
| <div class="output ports"> |
| {#if node.primaryOutput} |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 8 8" |
| class="port primary-port" |
| data-port="output" |
| 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)`} |
| > |
| <title>{`${dataTypeTooltip(node.primaryOutput)}\n\n${outputConnectedToText(node.primaryOutput)}`}</title> |
| {#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="port" |
| data-port="output" |
| data-datatype={secondary.dataType} |
| style:--data-color={`var(--color-data-${secondary.dataType.toLowerCase()})`} |
| style:--data-color-dim={`var(--color-data-${secondary.dataType.toLowerCase()}-dim)`} |
| > |
| <title>{`${dataTypeTooltip(secondary)}\n\n${outputConnectedToText(secondary)}`}</title> |
| {#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> |
| |
| |
| |
| {#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" global> |
| .graph { |
| position: relative; |
| overflow: hidden; |
| display: flex; |
| flex-direction: row; |
| flex-grow: 1; |
| |
| // 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(--dot-radius)) calc(var(--grid-offset-y) - var(--dot-radius)); |
| background-image: radial-gradient(circle at var(--dot-radius) var(--dot-radius), var(--color-3-darkgray) var(--dot-radius), transparent 0); |
| background-repeat: repeat; |
| image-rendering: pixelated; |
| mix-blend-mode: screen; |
| } |
| |
| > img { |
| position: absolute; |
| bottom: 0; |
| } |
| |
| .breadcrumb-trail-buttons { |
| margin-top: 8px; |
| margin-left: 8px; |
| } |
| |
| .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; |
| |
| .toggle-layer-or-node .text-label { |
| line-height: 24px; |
| margin-right: 8px; |
| } |
| |
| .merge-selected-nodes { |
| justify-content: center; |
| } |
| } |
| |
| .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; |
| } |
| |
| .port { |
| 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 { |
| position: absolute; |
| width: 100%; |
| height: 100%; |
| |
| .port { |
| 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; |
| |
| &.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; |
| width: 100%; |
| height: 100%; |
| } |
| |
| .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: rgba(var(--color-0-black-rgb), 0.33); |
| |
| .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; |
| bottom: calc(100% + 12px); |
| z-index: -1; |
| transition: opacity 0.2s ease-in-out; |
| opacity: 0.5; |
| |
| // 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; |
| -webkit-user-select: text; |
| user-select: text; |
| transition: |
| opacity 0.2s ease-in-out, |
| 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: rgba(var(--color-e-nearwhite-rgb), calc(254 / 255)); |
| } |
| } |
| } |
| } |
| } |
| |
| &::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: rgba(var(--color-4-dimgray-rgb), 0.33); |
| color: var(--color-a-softgray); |
| |
| .icon-label { |
| fill: var(--color-a-softgray); |
| } |
| } |
| |
| &.previewed::after { |
| border: 1px dashed var(--data-color); |
| } |
| |
| .ports { |
| position: absolute; |
| |
| &.input { |
| left: -3px; |
| } |
| |
| &.output { |
| right: -5px; |
| } |
| } |
| |
| .port { |
| // 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` port offset CSS rule above in `.ports.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: rgba(var(--color-5-dullgray-rgb), 0.33); |
| |
| &.in-selected-network { |
| background: rgba(var(--color-6-lowergray-rgb), 0.33); |
| } |
| } |
| |
| .thumbnail { |
| background: var(--color-2-mildblack); |
| border: 1px solid var(--data-color-dim); |
| border-radius: 2px; |
| position: relative; |
| box-sizing: border-box; |
| width: 72px; |
| height: 48px; |
| |
| &::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(.port) { |
| pointer-events: none; |
| position: absolute; |
| margin: auto; |
| top: 1px; |
| left: 1px; |
| width: calc(100% - 2px); |
| height: calc(100% - 2px); |
| } |
| |
| .port { |
| position: absolute; |
| margin: 0 auto; |
| left: 0; |
| right: 0; |
| height: 12px; |
| |
| &.top { |
| top: -13px; |
| } |
| |
| &.bottom { |
| bottom: -13px; |
| } |
| } |
| } |
| |
| .details { |
| margin: 0 8px; |
| |
| .text-label { |
| white-space: nowrap; |
| line-height: 48px; |
| } |
| } |
| |
| .solo-drag-grip { |
| width: 8px; |
| height: 24px; |
| background-position: 2px 8px; |
| right: calc(-12px + 24px); |
| border-radius: 2px; |
| } |
| |
| .solo-drag-grip:hover, |
| &.selected .solo-drag-grip { |
| background-image: var(--icon-drag-grip); |
| |
| &:hover { |
| background-image: var(--icon-drag-grip-hover); |
| } |
| } |
| |
| .visibility { |
| position: absolute; |
| right: -12px; |
| } |
| |
| .input.ports { |
| left: calc(-3px + var(--node-chain-area-left-extension) * 24px - 36px); |
| } |
| |
| .solo-drag-grip, |
| .visibility, |
| .input.ports, |
| .input.ports .port { |
| position: absolute; |
| margin: auto 0; |
| top: 0; |
| bottom: 0; |
| } |
| |
| .input.ports .port { |
| 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: rgba(var(--color-f-white-rgb), 0.15); |
| |
| &.in-selected-network { |
| background: rgba(var(--color-f-white-rgb), 0.2); |
| } |
| } |
| |
| .secondary { |
| background: rgba(var(--color-f-white-rgb), 0.1); |
| |
| &.in-selected-network { |
| background: rgba(var(--color-f-white-rgb), 0.15); |
| } |
| } |
| } |
| |
| .port { |
| &:first-of-type { |
| margin-top: calc((24px - 8px) / 2); |
| |
| &:not(.primary-port) { |
| 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: rgba(var(--color-f-white-rgb), 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> |
| |