6
\$\begingroup\$

I'm working on the backend of a community radio station website, and fancied trying my hand at writing a drag and drop scheduler.

It's working, but I feel I've made a mess with the drag and drop maths. What's the best way to handle an elements position in JavaScript?

I've used a combination of offsets from .pageX, .getBoundsClientRect and .offsetLeft etc. but I had a real struggle getting them to work together, I'm sure there must be some standard way to handle this stuff.

I'm not much cop with JavaScript so any style or structure advice would be appreciated and if someone could offer some advice on getting this into some sort of class that would be great too.

var gridHolder = document.getElementById("gridHolder"),
  gridAnchor = document.getElementById("gridAnchor"),
  grid = document.getElementById('grid'),
  staging = document.getElementById("staging"),
  showCount = 0,
  currentTarget,
  currentTargetRect,
  origin = gridAnchor.getBoundingClientRect(),
  dx, dy,
  intervals = 12,
  blockHeight = 44,
  blockWidth = 144,
  blockIntervalWidth = blockWidth / intervals,
  intervalValue = 60 / intervals,
  monday = new Date(),
  gridDays = document.createElement('div'),
  gridDaysWidth = 100,
  gridTimes = document.createElement('div'),
  gridTimesHeight = 24,
  days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];

gridHolder.addEventListener("mousedown", selectBlock);
document.getElementById("addShow").addEventListener("click", addShow);

monday.setHours(-((monday.getDay() - 1) * 24), 0, 0, 0);

gridDays.className = "grid-days";
gridDays.style.width = gridDaysWidth;
gridTimes.className = "grid-times";
gridTimes.style.height = gridTimesHeight;

grid.style.marginLeft = gridDaysWidth;

for (var i = 0; i < 7; i++) {
  var dayDiv = document.createElement('div'),
    day = new Date(monday);
  dayDiv.id = "day-" + i + 1;
  dayDiv.className = "grid-day";
  dayDiv.style.height = blockHeight + "px";
  day.setHours(i * 24);
  dayDiv.innerHTML = "<p>" + days[day.getDay()];
  gridDays.appendChild(dayDiv);
}

for (var i = 0; i < 24; i++) {
  var timeDiv = document.createElement('div'),
    time = new Date(monday);
  timeDiv.id = "day-" + i + 1;
  timeDiv.className = "grid-time";
  timeDiv.style.width = blockWidth + "px";
  time.setHours(i);
  timeDiv.innerHTML = "<p>" + formattedTime(time);
  gridTimes.appendChild(timeDiv);
}

gridHolder.insertBefore(gridTimes, gridAnchor);
gridHolder.insertBefore(gridDays, gridAnchor);

function addShow() {
  var select = document.getElementById("radioShows"),
    option = select.options[select.selectedIndex];
  if (option.text == "Select Show") {
    return;
  }
  showBar = createBlock(option.text, option.value);
  staging.appendChild(showBar);
}

function createBlock(showTitle, showID) {
  showCount++;

  var id = "radioShow-" + showCount,
    showBar = document.createElement("div"),
    title = document.createElement('p'),
    date = document.createElement('p');

  showBar.setAttribute("id", id);
  showBar.setAttribute("class", "radio-show color-one");

  showBar.dataset.title = showTitle;
  showBar.dataset.id = showID;

  title.innerHTML = showTitle;
  date.setAttribute('class', 'date meta');

  showBar.appendChild(title);
  showBar.appendChild(date);

  return showBar;
}

function selectBlock(ev) {
  if (!hasClass(ev.target, 'radio-show')) {
    return;
  }
  currentTarget = ev.target;
  currentTargetRect = currentTarget.getBoundingClientRect();

  dx = ev.offsetX + origin.left;
  dy = ev.offsetY + origin.top;
  if (!inResizeArea(currentTargetRect.width, ev.offsetX)) {
    document.addEventListener('onscroll', moveBlock);
    document.addEventListener('mousemove', moveBlock);
    document.addEventListener('mouseup', dropBlock);
  } else {
    document.addEventListener('mousemove', resizeBlock);
    document.addEventListener('mouseup', setBlockSize);
  }
}

function hasClass(element, className) {
  return element.className.indexOf(className) != -1;
}


function inResizeArea(blockWidth, offsetX) {
  return blockWidth - offsetX < blockIntervalWidth;
}

function moveBlock(ev) {
  var x = ev.pageX,
    y = ev.pageY;
  currentTarget.style.left = (x - dx) - gridDaysWidth + "px";
  currentTarget.style.top = (y - dy) - gridTimesHeight + "px";
}

function dropBlock() {
  if (isInGridBounds(currentTarget)) {
    grid.appendChild(currentTarget);
    snapBlock(currentTarget);
    blockBounds = currentTarget.getBoundingClientRect();
    setBlockDateTime()
  } else {
    currentTarget.remove();
  }
  document.removeEventListener('onscroll', moveBlock);
  document.removeEventListener('mousemove', moveBlock);
  document.removeEventListener('mouseup', dropBlock);
}

function resizeBlock(ev) {
  var x = ev.pageX;
  currentTarget.style.width = (x - currentTargetRect.left - window.scrollX) + "px";
}

function setBlockSize() {
  snapWidth(currentTarget);
  setBlockDateTime();
  document.removeEventListener('mousemove', resizeBlock);
  document.removeEventListener('mouseup', setBlockSize);
}

function isInGridBounds(element) {
  var bounds = grid.getBoundingClientRect();
  currentTargetBounds = element.getBoundingClientRect();
  return (currentTargetBounds.left > bounds.left && currentTargetBounds.top > bounds.top && currentTargetBounds.bottom < (bounds.bottom + blockHeight - 1) && currentTargetBounds.right < (bounds.right + blockWidth - 1));
}

function snapWidth(element) {
  var left, right,
    snapTo = blockIntervalWidth,
    currentTargetBounds = element.getBoundingClientRect();
  right = currentTargetBounds.right;
  left = currentTargetBounds.left;
  if ((right - left) % snapTo != 0) {
    var snapPoint = right - left + (snapTo - Math.ceil((right - left) % snapTo));
    element.style.width = snapPoint + "px";
  }
}

function snapBlock(element) {
  var left, top,
    snapToLeft = blockIntervalWidth,
    snapToTop = blockHeight;
  currentTargetBounds = element.style.left;

  left = parseInt(element.offsetLeft);
  top = parseInt(element.offsetTop);

  if (left % snapToLeft != 0) {
    element.style.left = Math.floor(left - (left % snapToLeft)) + "px";
  }
  if (top % snapToTop != 0) {
    element.style.top = Math.floor(top - (top % snapToTop)) + "px";
  }
}

function setBlockDateTime() {
  var left = parseInt(currentTarget.offsetLeft),
    right = currentTarget.getBoundingClientRect().width + left,
    top = parseInt(currentTarget.offsetTop);

  startTime = pixelsToTime(left);
  endTime = pixelsToTime(right);
  date = pixelsToDate(top);

  setTimeAndDateOnBlock(currentTarget, startTime, endTime, date)
}

function setTimeAndDateOnBlock(element, startTime, endTime, date) {
  element.lastChild.innerHTML = startTime + '–' + endTime;
  element.dataset.date = date;
}

function pixelsToTime(sidePosition) {
  var minutes,
    dateTime = new Date(monday);

  minutes = Math.round(sidePosition / blockIntervalWidth) * intervalValue;
  dateTime.setMinutes(minutes);

  return formattedTime(dateTime);
}

function pixelsToDate(top) {
  var day = blockHeight,
    dateTime = new Date(monday);

  day = Math.round(top / day);
  hoursInDay = 24;
  dateTime.setHours((day - 1) * hoursInDay);

  return formattedDate(dateTime);
}

function formattedDate(date) {
  return date.getFullYear() + "-" + padZeroes(date.getMonth() + 1) + "-" + padZeroes(date.getDate());
}

function formattedTime(date) {
  return padZeroes(date.getHours()) + ':' + padZeroes(date.getMinutes());
}

function padZeroes(n) {
  return (n < 10) ? ("0" + n) : n;
}

/**
 * Load data from API
 */


// var selectData = jsonRequest("http://localhost:63342/snap-grid/data/select-data.json", "GET", setSelectData);
// var radioShowData = jsonRequest("http://localhost:63342/snap-grid/data/radio-show-data.json", "GET", setRadioShowData);

function setRadioShowData() {
  // var data = JSON.parse(radioShowData.response);
  var data = {
    "radio_shows": [{
      "id": 1,
      "name": "Daves Show",
      "profile_picture": "show.png",
      "start_time": "2017-01-10T01:00:00+0000",
      "end_time": "2017-01-10T03:00:00+0000"
    }, {
      "id": 2,
      "name": "Daisys Show",
      "profile_picture": "show.png",
      "start_time": "2017-01-11T02:00:00+0000",
      "end_time": "2017-01-11T03:00:00+0000"
    }, {
      "id": 3,
      "name": "Ronalds Show",
      "profile_picture": "show.png",
      "start_time": "2017-01-10T03:00:00+0000",
      "end_time": "2017-01-10T04:00:00+0000"
    }, {
      "id": 4,
      "name": "Cynthias Show",
      "profile_picture": "show.png",
      "start_time": "2017-01-12T04:30:00+0000",
      "end_time": "2017-01-12T07:35:00+0000"
    }]
  }

  for (var i = 0; i < data.radio_shows.length; i++) {
    var radioShow = data.radio_shows[i],
      startTime = convertedDate(radioShow.start_time),
      endTime = convertedDate(radioShow.end_time),
      startTimeMinutes = startTime.getHours() * 60 + startTime.getMinutes(),
      endTimeMinutes = endTime.getHours() * 60 + endTime.getMinutes(),
      startTimePosition = (startTimeMinutes / 5) * intervals,
      endTimePosition = (endTimeMinutes / 5) * intervals,
      top = (startTime.getDay() - 1) * blockHeight;

    showBar = createBlock(radioShow.name, radioShow.id);
    setTimeAndDateOnBlock(showBar, formattedTime(startTime), formattedTime(endTime), formattedDate(startTime));
    showBar.style.left = startTimePosition + "px";
    showBar.style.top = top + "px";
    showBar.style.width = endTimePosition - startTimePosition + "px";
    grid.appendChild(showBar);
  }
}

function setSelectData() {
  // var data = JSON.parse(selectData.response),
  var data = {
    "radio_shows": [{
      "id": "1",
      "name": "Show One"
    }, {
      "id": "2",
      "name": "Show Two"
    }, {
      "id": "3",
      "name": "Show Three"
    }, {
      "id": "4",
      "name": "Show Four"
    }]
  }

  var select = document.getElementById("radioShows");

  for (var i = 0; i < data.radio_shows.length; i++) {
    var option = document.createElement("option"),
      radioShow = data.radio_shows[i];
    option.value = radioShow.id;
    option.innerHTML = radioShow.name;
    select.appendChild(option);
  }
}

function convertedDate(dateTime) {
  return new Date((dateTime).replace(/-/g, "/").replace(/[TZ]/g, " ").replace("/+0000/g", ""));
}

function jsonRequest(url, method, callback) {
  var res = new XMLHttpRequest();
  res.addEventListener('load', callback);
  res.open(method, url, true);
  res.send();
  return res;
}


setRadioShowData();
setSelectData();
div {
  box-sizing: border-box;
}
.wrapper {
  padding: 10px;
}
.grid-holder {
  display: inline-block;
  width: 4000px;
}
.grid-anchor {
  position: relative;
  float: left;
}
.grid-times {
  margin-left: 100px;
  height: 24px;
}
.grid-days {
  padding-top: 12px;
  padding-right: 8px;
  width: 100px;
  float: left;
  text-align: right;
}
.grid-days p,
.grid-times p {
  margin: 0;
  padding: 0;
}
.grid-time {
  display: inline-block;
}
.grid {
  width: 3456px;
  height: 308px;
  border: 1px solid slategrey;
  background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAABYCAIAAACVn+k2AAAABmJLR0QA/wD/AP+gvaeTAAABU0lEQVR4nO3VQa3DUBDFUM8H8KiGQACGQIiEQAqhv1Kl1JK9vovZHM3c901Jmpm/t6PjOHSbbdt+6p5vbYBJmKiEyTYkzFXCZBsS5iphsg0Jc5Uw2YaEuUqYbEPCXCVMtiFhrhIm25AwVwmTbUiYq4TJNiTMVcJkGxLmKmGyDQlzlTDZhoS5SphsQ8JcJUy2IWGuEibbkDBXCZNtSJirhMk2JMxVwmQbEuYqYbINCXOVMNmGhLmamadPqA+b67qevqH+21rr/Q87z1O32ff9p+751oaEuUqYbEPCXCVMtiFhrhIm25AwVwmTbUiYq4TJNiTMVcJkGxLmKmGyDQlzlTDZhoS5SphsQ8JcJUy2IWGuEibbkDBXCZNtSJirhMk2JMxVwmQbEuYqYbINCXOVMNmGhLlKmGxDwlwlTLYhYa4SJtuQMFcJk21ImKu11tMn1Ie9AMKvtvAicf0dAAAAAElFTkSuQmCC");
}
.radio-show {
  position: absolute;
  width: 144px;
  height: 44px;
  padding: 6px 8px 8px 44px;
  color: slategrey;
  z-index: 1;
  background: whitesmoke url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAsCAIAAACR5s1WAAAABmJLR0QA/wD/AP+gvaeTAAADaklEQVRYhe2XT0/iQBjGBzC2lVrRRKj0hsWDeIWEHkwA5eJn8Ct69NIU46VNekU8oYZESuXU1sEMprZ7aHh3ttTK7sH1wHOad/68/bUz88w0E4Yh+t/K/m8AhNYQv7WGWGgjsVZV1dFoFARBPp/vdrv7+/t0q+/7pmk+Pj4ihCqVSqPR2Nj4Iw/G2DCM8XjMcVytVjs5OUmHyCxvUVVVn56eIMzlcpeXl5ubm1CjadrDwwOEsiy3220a8erqynEcqFEUJZ0jYTpGoxEdfnx89Pt9CAkhNAFCaDgcEkIgtCyLJkAIDQaDFIJkiOVvM5vNaIjlIfP5PKVD4pAvILa2tmI1R0dHUN7Z2eF5nm7leV4QBAhFUcxm/0grSdJfQ5ydneVyOQir1aooihBmMplWq8WybBSyLNtutzOZDHQQBEFRFODY3d1tNpvpEAkLEyH0/v7e7/ff3t5iBCBCiGVZCKFyuQxAtDzPe3l5YRhGkiT6lf4C4pv1I8zqZ0MQQhzHSZksjDHG+LPWMAwdx/lyc0ZKsG3f929vbyNH4nm+1WodHBzQHV5fXzVNm06nCKFisdjpdLa3t+kOk8mk1+tF7iLL8unpaczXY0r4EqZpgidijFVVjb0QECCEptOppml0KyFEVVXwt+FwaJpmCkEyRHQy0Umj3QhYQAAc9LyMx+MYdCzhShD/INqs6PJnNV9DVCoVOmRZtlwuQ8jzfLFYpDuUSqV8Pg/hsn3FEq4E0Wg0ZFmGR3a73VjSTqcDHKVSiT7HI+jz83M4X2RZrtfr6RCfOuZ8PieECILw2ceM1kHsMAOFYei6LsdxDMOkE6RBfKd+tmN+p5KNzPd9y7IIIaIo0hcW0CpHuW3bLMuucpQnQGCMr6+vo3tiNptVFOX4+JjuMJlMwEajvRDz9fv7e13XgyBACBUKhYuLC3oPLythOgzDgJtqEAS6rnueB61hGPZ6PfBEQsjNzQ29uj3PAwKEkOM4uq6nECRD0CYdcdi2DaHruvS9FyGEMXZdF0LbtoEgMeFKEMtzzHFcYjlxSPrwVSFqtRodFgoF2rYZhgE/jVStVukHS5K0t7eXknBZyWZ1d3c3GAwIIZIkNZvN2LKifwMPDw/r9XrsujCbzQzDeH5+/vffwO/XjzCrNcRCa4iF1hALrSEW+gWcEa9+YBXXcwAAAABJRU5ErkJggg==") top left no-repeat;
  cursor: pointer;
}
.radio-show p {
  margin: 0;
  padding: 0;
  font-size: 12px;
}
.radio-show:after {
  content: "";
  position: absolute;
  top: 0;
  right: -10px;
  display: block;
  height: 44px;
  width: 10px;
  background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAsCAIAAADTpb7BAAAABmJLR0QA/wD/AP+gvaeTAAAAHklEQVQ4jWP8//8/A27AhEduVHpUelR6VHpUGg0AANa+A1VYGRD1AAAAAElFTkSuQmCC") center right no-repeat;
  cursor: col-resize;
  z-index: 4;
}
.staging {} .staging .radioShow {
  display: inline-block;
}
.front {
  z-index: 3;
}
.color-one {
  border: 2px solid deeppink;
  border-right-width: 12px;
}
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Snap Grid</title>
  <link href="css/styles.css" rel="stylesheet" />
</head>

<body id="body">
  <div class="wrapper">
    <h1>Radio Schedule</h1>
    <div class="grid-holder" id="gridHolder">
      <div class="grid-anchor" id="gridAnchor">
        <div class="grid" id="grid"></div>
        <div class="staging" id="staging">
          <div class="button-holder">
            <select id="radioShows">
              <option>Select Show</option>
            </select>
            <button id="addShow">Add Radio Show</button>
          </div>
        </div>
      </div>
    </div>
  </div>
  <script src="snap-grid.js"></script>
  <script src="load-data.js"></script>
</body>

</html>

\$\endgroup\$
  • \$\begingroup\$ Is this supposed to work on touch devices as well? And is there a reason why you try to implement your own drag and drop library? There're quite good and lightweight libraries out there, like interact.js, which support mouse control and gestures and handle differences between browsers etc. as well. You could concentrate on your business logic and don't need to worry about this functionality. \$\endgroup\$ – insertusernamehere Jan 13 '17 at 13:02
  • 1
    \$\begingroup\$ I'm doing it for the fun of it. I tried out interact.js, there seemed to be a few pixel rounding errors with what I did with it, I'm not a fan of libraries they are never quite what I want. Would be good if it did work for touch, but it isn't an issue at the moment as I know who is going to be using it, I'd be likely to add features like that in the future. \$\endgroup\$ – Sarcoma Jan 13 '17 at 13:13
  • \$\begingroup\$ As a personal preference I would reduce the threshold before an item is dropped into the day above the current one. If you want to move an item within a day and the container does slightly touch the day above, it will move the element up. Same happens when you explicitly want to drop an item on the day below. It has to be fully below the line, otherwise it stays in the current day. \$\endgroup\$ – insertusernamehere Jan 13 '17 at 13:17
  • \$\begingroup\$ Yeah it gets even worse when you are on the top row as it gets removed completely. It comes back in part to the issues I'm having with my maths, once I have tamed that I'll iron out the wrinkles. I'll probably make the holding box larger than the item box to allow some leeway. \$\endgroup\$ – Sarcoma Jan 13 '17 at 13:21
3
+100
\$\begingroup\$

There is a lot of code so I can not address every aspect of your code.

First impression.

It works, just, but is very unfriendly.

Cursors

Cursors are a must when doing any form of mouse interaction. When over a draggable item the cursor should be "grab" and then to "grabbing" or "none" when dragging. The CSS standard cursor set is very limited, learn how to create your own cursors and use them. I can not overstate how important cursors are to good UI design. They are the forgotten information provider in may apps.

Select drag!

One thing I find it very annoying, even professional sites seem to miss this, Turn off "select drag" when mouse is over draggable items. CSS rule user-select: none; and appropriate prefixes if needed. It just looks horrible when a big blue area is selected just for dragging something.

Smarter drop

Don't allow dropped items to overlap. Move the dropped item to fit in front of or behind items under it. If you drop over an item and there is another after insert the new item after the current and move the next one to give room.

Snap to the day the mouse is over (if showing it) or to the day nearest the center of the control.

Scale

Use the mouse wheel input to scale in and out (if not over item). The current screen is way to wide. You may also allow that when the mouse is over an item that the mouse wheel moves it left and right. (with appropriate cursor)

Bits and bobs

  • Fade the dragged item so you can see what is under.
  • It seems to hold the item sometimes even with the mouse button up.
  • Provide a clear disposal icon to allow the item to be removed. Drag block over dispose icon to remove.
  • Allow a modifier for creating a copy. Eg [ctrl] drag or Right click drag to copy.

I noticed a comment about touch devices. DONT try and code a touch interface and mouse interface on the same page. Detect the interface and load the appropriate interface

Also someone suggested using a pre existing library. Javascript libraries I find to be very poor quality. If you do use one, befor you do, look at the source code, look at the documentation. If you find the code hard to read and the documentation sparse then don't use it. Just because a lib has a lot of stars (what not) does not make it a good lib. I see more time wasted wrangling inappropriate, overly complex, or just plain unfinished and buggy libs than would have ever been spent creating the functionality you need yourself.

The Code

Style!

Before you write another line of code read Airbnb JavaScript Style Guide. Once you have read it, read it again in a week, and agian in a month. Learn it inside out. Style is the most important aspect of coding in any language. Bad and inconsistent style is the leading cause of bugs in all languages.

I personal do not agree with all the style guidelines in the document, but I have sound and good reason for why. If you can not find reason not to adopt a style in the document then do as the document says. The benefits will show as soon as you start coding to the consistent style.

That takes care of style and I will address the code design.

Code design

The code is all over the place, just a sea of seemingly unrelated functions intermixed with the odd bit of immediate functionality. This is very hard to manage as the code base grows. Break the application up into smaller and related parts.

I was going to add "data does not belong in code" but I see you have commented out the AJAX fetches. Still for experimenting you should still use data external to any of the code.

Abstractions and modular design.

Your app is mixing core functionality, information , UI and rendering. These things should exist independently of each other. What is the core of the app (to schedule radio shows) It should be able to do all the things that a scheduler is expected to do independently of any UI or Display. It does this via an interface to some core abstract types.

You need to design some objects that together create the core functionality of the app.

What follows is only a suggestion and not at all an in depth analysis of your application. These are only to give example of how modules may relate and communicate.

Some suggested abstract objects. Timeline, Show.

Some related functionality. Timeline.addShow, Timeline.removeShow, Timeline.nextShowTime, Timeline.rescedualShow, Timeline.isTimeFree, Timeline.showAtTime, etc...

Show.setName, Show.getLength, Show.hasPlayed, 'Show.getId' etc...

Having the information you are managing separate from the display will make the task of displaying that information a lot easier.

Renderer and UI

A renderer interrogates the Timeline and Show interfaces to workout what to display and where.

A UI interface that is strongly tied to the render and is a middle man between the renderer and the Timeline, Show interfaces.

Remember the actual functionality of your app, to schedule play times must be able to function independently of a front end.

Example of module communication.

The UI asks the renderer "what is under the mouse?" The renders gives a showId, the ui then asks the timeline, "Can I move the showId?" If the response is yes it tells the renderer that the cursor should be a grab icon. The render will set the grab cursor at the next scheduled render refresh (use requestAnimationFrame to maintain a render loop)

If you are dragging an item the UI will ask the renderer, "What is the day and time under the dragging item?" You use that information to ask the timeline, "Can the show be dropped at that time?" If so inform the render to show this fact.

The renderer only renders and keeps information regarding the rendering. Information it holds is where and what size the various shows are. Where the timeline is displayed and how screen coordinates relate to time and date. The ui will ask, what time or day is the mouse at, The render can hold identifiers that can be used by itself and the UI to query the timeline and shows

The render asks the Timeline and Show interfaces. Has there been a change to the data that needs to be updated? The render always asked and is never told. The renders is the slow link in the chain of information display, let it keep its own pace.

It may seem like a lot of extra work but in reality if done well it is much more efficient

You asked;

"What's the best way to handle an element's position in JavaScript?"

Personally I would say, forget the DOM and use a canvas, but that will upset the the believers in semantic page design.

I all I can say is maybe rather than use relative positioning do all elements via absolute positioning, that way you don't have to fight the pages layout engine. If you have correctly separated rendering from the apps function then you are free to experiment with rendering methods.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.