|
<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> |
|
|