graphite2 / frontend /src /components /views /Graph.svelte
openfree's picture
Deploy from GitHub repository
2409829 verified
Raw History Blame Contribute Delete
46.1 kB
<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; // Key value is node id + input/output index // Imports/Export are stored at a key value of 0 $: 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 }[] = []; // 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 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 > <!-- Right click menu for adding nodes --> {#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} <!-- Click target debug visualizations --> {#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} <!-- Thick vertical layer connection wires --> <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> <!-- Import and Export ports --> <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> <!-- Layers and nodes --> <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})`}> <!-- Layers --> {#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} <!-- Layer stacking top output --> {#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} <!-- Layer stacking bottom input --> <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> <!-- Layer input port (from left) --> {#if node.exposedInputs.length > 0} <div class="input ports"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" class="port" data-port="input" data-datatype={stackDataInput.dataType} style:--data-color={`var(--color-data-${stackDataInput.dataType.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"> <!-- TODO: Allow the user to edit the name, just like in the Layers panel --> <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}> <!-- 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 $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> <!-- Nodes --> {#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} <!-- Primary row --> <div class="primary" class:in-selected-network={$nodeGraph.inSelectedNetwork} class:no-secondary-section={exposedInputsOutputs.length === 0}> <IconLabel icon={nodeIcon(node.reference)} /> <!-- 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 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} <!-- Input ports --> <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> <!-- Output ports --> <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> <!-- Box selection widget --> <!-- TODO: Make its initial corner stay put (in graph space) when panning around --> {#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>
<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;
// Key value is node id + input/output index
// Imports/Export are stored at a key value of 0
$: 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 {