Blog Post - Building Node-Based UIs with Svelvet

Published on: 2023-09-18

, by Daniel Markow

Svelte is my new favorite framework to use. It just makes sense for me and compared to react everything feels more transparent and intuitive. For a project idea I began exploring it. Part of that idea is to build node-based UIs. I knew that in the react-world there is react-flow. In the Svelte world there is Svelvet which is kind of sparsely documented. Thus I want to take this opportunity to present a path to build a basic node based UIs with it for whomever it may be useful.

The application we are going to build features a canvas to which nodes will added. The newly added nodes will be distributed in a snake-like pattern over the canvas. On click the nodes will editable.

To get started initialize a new SvelteKit project

pnpm create sveltekit@latest

I used typescript in my project. Then proceed to install our only dependencies

pnpm install svelvet nanoid

I am using Typescript in my project but this should work with JavaScript and JSDocs as well with minimal adjustments if that is your preference.

Next we are going to introduce a data structure that contains all the information we need to render out nodes and anchors. Anchors define a connection between the nodes. It is not the edge that defines the connection. The edge is only the structure that is displayed as a result of two anchors being connected. If you don’t define an edge, the default will be used.

let initialNodes = [
  {
    id: 1, // gets transformed to id string 'N-1', 0 is not supported
    position: { x: 10, y: 10 },
    label: "Output Node",
    dimensions: { width: 175, height: 40 },
    anchors: [
      {
        id: "anchor1",
        connections: [0, ""],
        output: true,
        dynamic: true,
      },
      {
        id: "anchor2",
        connections: [0, ""],
        output: false,
        dynamic: true,
      },
    ],
  },
];

This reactive data structure is then used to render nodes and enchors.

<Svelvet height={CANVASHEIGHT} width={CANVESWIDTH}>
	{#each initialNodes as initialNode}
		<Node
			id={initialNode.id}
			position={initialNode.position}
			dimensions={initialNode.dimensions}
			useDefaults
			on:nodeClicked={() => setEditNode(initialNode.id)}
		>
			<p>{initialNode.label}</p>
			{#each initialNode.anchors as anchor}
				<Anchor
					id={anchor.id}
					output={anchor.output}
					input={!anchor.output}
					dynamic={anchor.dynamic}
					connections={anchor.connections}
				/>
			{/each}
		</Node>
	{/each}
</Svelvet>

The Svelvet tag initializes the canvas wherein we iterate over the nodes. The “useDefaults” hook in the node tag tells Svelvet that we want to style it according to the defaults. If you start customizing the node as I did here adding the label with paragraph tags it will shed its default styling expecting you to do the job unless you use the defaults-hook. We render the anchors as dynamic which means they will be automatically re-positioned if the nodes are moved around.

We proceed to define an input and a button for defining a new node (we will only customize the label for now). The button will execute this function

function addNode() {
  const newNodeDimensions = { width: 175, height: 40 };
  const lastNodePostion = initialNodes.filter(
    (node) => node.id === nodeCounter
  )[0].position;

  if (creationMode === "lr") {
    let newNodePosition = { x: lastNodePostion.x + 225, y: lastNodePostion.y };

    // right side boundary
    if (newNodePosition.x + newNodeDimensions.width > CANVESWIDTH) {
      newNodePosition = { x: lastNodePostion.x, y: lastNodePostion.y + 100 };
      creationMode = "rl";
    }
    initialNodes = [
      ...initialNodes,
      {
        id: nodeCounter + 1,
        position: newNodePosition,
        label: newLabel,
        dimensions: newNodeDimensions,
        anchors: [
          {
            id: nanoid(),
            connections: [0, ""],
            output: true,
            dynamic: true,
          },
          {
            id: nanoid(),
            connections: [0, ""],
            output: false,
            dynamic: true,
          },
        ],
      },
    ];
  }

  if (creationMode === "rl") {
    let newNodePosition = { x: lastNodePostion.x - 225, y: lastNodePostion.y };

    // left side boundary
    if (newNodePosition.x + newNodeDimensions.width < 0) {
      newNodePosition = { x: lastNodePostion.x, y: lastNodePostion.y + 100 };
      creationMode = "lr";
    }
    initialNodes = [
      ...initialNodes,
      {
        id: nodeCounter + 1,
        position: newNodePosition,
        label: newLabel,
        dimensions: newNodeDimensions,
        anchors: [],
      },
    ];
  }

  newLabel = "";
  nodeCounter += 1;
}

The creation mode is initialized to “lr” meaning from left to right. We then keep adding nodes until we touch the canvas boundary. Then a new from beneath the last node is started and the creation mode is set to “rl” (from right to left). We then may keep adding nodes until we reach the left side of the canvas when the next row is created and creation mode once again switched.

Obviously this function does not account for all the edge cases. For example what if we start with multiple initial nodes which are in “rl” mode we will end up laying nodes on top of each other even if we initialize the node counter correctly. It’s a start though…

You might have noticed the “on:nodeClicked” on the node tag and the executed function “setEditNode”. We have created a second input field below the input for creating a node and bound it to the state editNote label. On click we find the node in question and put it in the state destined to be edited.

function setEditNode(nodeId: number) {
  const nodeToEdit = initialNodes.find((n) => n.id === nodeId);
  if (nodeToEdit) {
    editNode = nodeToEdit;
  }
  showEditNode = true;
}

We make our edits and the replace the edited note in the original data structure.

function saveNode(editNodeId: number) {
  initialNodes = [...initialNodes.filter((n) => n.id !== editNodeId), editNode];
  showEditNode = false;
}

I hope this was helpful. You can find the full source code for this example here.