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.
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 = "✕"
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)
can you provide full code, since copying snippets is not working.
Can you share me the full code as a zip. Code snippet is not working.