DEV Community

Danilo Šekara
Danilo Šekara

Posted on

[How To] Connect Elements With Lines on Web Page

Recently I had a project for Institute of Textbooks where I had to make an WEB application with tasks from their 5th grade textbook. There was nine types of tasks and one of them was to connect words(or sentences) with lines. I knew that HTML has no native support for this kind of stuff so I had to improvise somehow. Of course that first thing that I've done was to look for some JS library but anything that I could find was not lightweight and has a lot more features that I needed. Also this WEB application should be responsive and supported on touch devices and older browsers(latest versions of Chrome and Firefox supported by Windows XP(don't ask...)).

Sneak peak of final result ✅

Here you can see the final result how it looks when you connect some words with another and check if connections are correct.
Sneak peak of final result

The idea 💡

At first I though about using div's with absolute position, 2-3px height and dynamical width(calculated distance between two hooks) and also rotation with rotation origin in the left top(or bottom), but that was just awful.

Two minutes later I thought about canvas, we all know that canvas should be used for drawings like this but canvas has one(well actually probably many but one in this case) drawback, it's just drawing and we cannot modify elements when already drawn(we can, but then we must redraw entire canvas).

SVG. Scalable Vector Graphics. This is the answer. Main difference between Canvas and SVG is that Canvas is bitmap(pixels and colors) and SVG keeps all his elements in HTML DOM. So if you want graphics intensive stuffs you should use Canvas, and if you want graphics with ability to modify elements and you will not have a lot of them(because it will affect performance drastically) then you should use SVG.

But, how? 🤔

I have to mention that I didn't use exact this code in my project, I'm posting simplified version so you can get an idea and implement as you want.

Okay, at this point we know that we'll use SVG for drawing lines and other content will be plain HTML. In order to achieve what we want, we will make structure like this

<div class="wrapper">
  <svg></svg>
  <div class="content">
    <ul>
      <li>One <div class="hook" data-value="One" data-accept="First"></div></li>
      <li>Two <div class="hook" data-value="Two" data-accept="Second"></div></li>
      <li>Three <div class="hook" data-value="Three" data-accept="Third"></div></li>
    </ul>
    <ul>
      <li><div class="hook" data-value="Second" data-accept="Two"></div> Second</li>
      <li><div class="hook" data-value="Third" data-accept="Three"></div> Third</li>
      <li><div class="hook" data-value="First" data-accept="One"></div> First</li>
    </ul>
  </div>
</div>

As you can see, I'm using datasets to describe my hooks(points for drawing and attaching corresponding lines).

And some CSS to arrange content properly

.wrapper {
  position: relative;
}
.wrapper svg {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
  shape-rendering: geometricPrecision; /* for better looking lines */
}
.wrapper .content {
  position: relative;
  z-index: 2;
  display: flex;
  justify-content: space-evenly;
  align-items: center;
}
.wrapper .hook {
  background-color: blue;
  display: inline-block;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  cursor: pointer;
}

Now we have all set up and it's time for some JavaScript.

const wrapper = document.querySelector(".wrapper")
const svgScene = wrapper.querySelector("svg")
const content = wrapper.querySelector(".content")

const sources = []
let currentLine = null
let drag = false

sources will contain lines with their start and end hooks, in currentLine we'll store current line we drawing and drag will tell us if we are currently drawing a new line.

As I mentioned before, this code should work both on desktop and mobile(touch) devices so I had to write code which will work in both cases.

First we will attach event listeners

wrapper.addEventListener("mousedown", drawStart)
wrapper.addEventListener("mousemove", drawMove)
wrapper.addEventListener("mouseup", drawEnd)

wrapper.addEventListener("touchstart", drawStart)
wrapper.addEventListener("touchmove", drawMove)
wrapper.addEventListener("touchend", drawEnd)

See that I'm using same methods for mouse and touch events.

drawStart()

Since this method is attached on wrapper and not on hook, first thing we should do is to check if user has started drawing line from correct point

if(!e.target.classList.contains("hook")) return

Second thing is to capture mouse(or touch) X and Y coordinates

let eventX = e.type == "mousedown" ? e.clientX - scene.offsetLeft : e.targetTouches[0].clientX - scene.offsetLeft
let eventY = e.type == "mousedown" ? e.clientY - scene.offsetTop + window.scrollY : e.targetTouches[0].clientY - scene.offsetTop + window.scrollY

And to draw a line

let lineEl = document.createElementNS('http://www.w3.org/2000/svg','line')
currentLine = lineEl;
currentLine.setAttribute("x1", eventX)
currentLine.setAttribute("y1", eventY)
currentLine.setAttribute("x2", eventX)
currentLine.setAttribute("y2", eventY)
currentLine.setAttribute("stroke", "blue")
currentLine.setAttribute("stroke-width", "4")

svgScene.appendChild(currentLine)
sources.push({ line: lineEl, start: e.target, end: null })

drag = true

Hey but we don't have second point coordinates?!?! Yep, that's right, that's where drawMove() kicks in. You see that we set our drag flag to true.

drawMove()

This method is invoked when user moves mouse(or touch) on our wrapper element, so first thing we have to do is to check if user is drawing a line or just moving his mouse(touch)

if (!drag || currentLine == null) return

Second thing here is the same as from drawStart()

let eventX = e.type == "mousedown" ? e.clientX - scene.offsetLeft : e.targetTouches[0].clientX - scene.offsetLeft
let eventY = e.type == "mousedown" ? e.clientY - scene.offsetTop + window.scrollY : e.targetTouches[0].clientY - scene.offsetTop + window.scrollY

And finally we update second point coordinates of line

currentLine.setAttribute("x2", eventX)
currentLine.setAttribute("y2", eventY) 

At this stage you will have your scene with hooks and you'll be able to draw line with one point attached on hook and second point following your mouse(or touch) until you release your mouse button(or move your finger from screen) and line will freeze. Let's move on next method.

drawEnd()

This method is invoked when user release mouse button or move his finger off screen, so first we have to ensure that he's been drawing a line

if (!drag || currentLine == null) return

Second thing is to define our targetHook

let targetHook = e.type == "mouseup" ? e.target : document.elementFromPoint(e.changedTouches[0].clientX, e.changedTouches[0].clientY)

See that I used e.target for mouseup event and document.elementFromPoint() for touch devices to get targetHook? That's because e.target in mouseup event will be element we currently hovering and in touchend event it will be element on which touch started.

What if user want to attach end of line on element which is not hook or to hook where line started? We will not allow that.

if (!targetHook.classList.contains("hook") || targetHook == sources[sources.length - 1].start) {
  currentLine.remove()
  sources.splice(sources.length - 1, 1)
} else {
  // patience, we'll cover this in a second
}

And finally if the end of the line is on correct position

if (!targetHook.classList.contains("hook") || targetHook == sources[sources.length - 1].start) {
  currentLine.remove()
  sources.splice(sources.length - 1, 1)
} else {
  sources[sources.length - 1].end = targetHook

  let deleteElem = document.createElement("div")
  deleteElem.classList.add("delete")
  deleteElem.innerHTML = "&#10005"
  deleteElem.dataset.position = sources.length - 1
  deleteElem.addEventListener("click", deleteLine)
  let deleteElemCopy = deleteElem.cloneNode(true)
  deleteElemCopy.addEventListener("click", deleteLine)

  sources[sources.length - 1].start.appendChild(deleteElem)
  sources[sources.length - 1].end.appendChild(deleteElemCopy)
}

drag = false

Now we have to implement deleteLine() method to allow our user to delete line.

First some CSS

.wrapper .hook > .delete {
  position: absolute;
  left: -3px;
  top: -3px;
  width: 21px;
  height: 21px;
  background-color: red;
  color: white;
  display: flex;
  justify-content: center;
  align-items: center;
  border-radius: 50%;
}
.wrapper .hook:hover {
  transform: scale(1.1);
}

and implementation of deleteLine()

let position = e.target.dataset.position

sources[position].line.remove();
sources[position].start.getElementsByClassName("delete")[0].remove()
sources[position].end.getElementsByClassName("delete")[0].remove()
sources[position] = null

And what about checking if words are connected properly?
Method checkAnswers()

sources.forEach(source => {
  if (source != null) {
    if (source.start.dataset.accept.trim().toLowerCase() == source.end.dataset.value.trim().toLowerCase() && source.end.dataset.accept.trim().toLowerCase() == source.start.dataset.value.trim().toLowerCase()) {
      source.line.style.stroke = "green"
    } else {
      source.line.style.stroke = "red"
    }
  }
})

THE END 🎉

That's all, now you have fully implemented drag'n'draw line functionality with minimum use of uncommon html tags and best of all, it works both on non-touch and touch devices!

I hope you liked this article and learned something new 😊

Top comments (2)

Collapse
 
rashidabbas profile image
rashid-abbas

can you provide full code, since copying snippets is not working.

Collapse
 
naveenkumar1050 profile image
naveenkumar1050

Can you share me the full code as a zip. Code snippet is not working.