4
\$\begingroup\$

Feature requirements.

  • I can move a square box.
  • I can resize a square box by stretching four edges.
  • Many square boxes can exist.

I've implemented moving and resizing square boxes by module pattern. Of course, I thought making a square box as a Class. But I think that using module pattern is better in this case.

Module Pattern

  • Pros
    • Can manage only focused element. Don't need to care about unfocused elements.
    • Can control event handling in it.
    • Can hide private variables and methods.
  • Cons
    • Maybe need many changes by changing features.
    • Test issue.

Class

  • Pros
    • Can manage and maintain properties of each square box.
    • Can be more clean code.
  • Cons
    • Should manage many instances.
    • Should add additional information to the DOM for finding each instance.

So, I implemented as module pattern. It looks like not good for test and can be more clear code. What should I do more for clean code or more testable?

https://jsfiddle.net/docf1t40/2/

  <!DOCTYPE html>
  <html>
    <head>
      <title>Practice</title>
      <style type="text/css">
        * {
          margin: 0;
          padding: 0;
          box-sizing: border-box;
        }
        .plate {
          position: absolute;
          width: 100%;
          height: 100%;
          background: rgb(240, 240, 240);
        }
        .edge {
          position: absolute;
          width: 12px;
          height: 12px;
          border-radius: 6px;
          background: rgb(211, 211, 211);
        }
        .edge.lt {
          top: -6px;
          left: -6px;
          cursor: nwse-resize;
        }
        .edge.rt {
          top: -6px;
          right: -6px;
          cursor: nesw-resize;
        }
        .edge.lb {
          bottom: -6px;
          left: -6px;
          cursor: nesw-resize;
        }
        .edge.rb {
          bottom: -6px;
          right: -6px;
          cursor: nwse-resize;
        }
      </style>
    </head>
    <body>
      <script type="text/javascript">
        const Square = (function() {
          let userAction;
          let focusedElement = {
            DOM: null,
            width: 0,
            height: 0,
            screenX: 0,
            screenY: 0,
            translateX: 0,
            translateY: 0,
          };

          function focusElement(dom, width, height, sx, sy, tx, ty) {
            focusedElement.DOM = dom;
            focusedElement.width = width;
            focusedElement.height = height;
            focusedElement.screenX = sx;
            focusedElement.screenY = sy;
            focusedElement.translateX = tx;
            focusedElement.translateY = ty;
          }

          function blurElement() {
            focusedElement = {
              DOM: null,
              width: 0,
              height: 0,
              screenX: 0,
              screenY: 0,
              translateX: 0,
              translateY: 0,
            };
          }

          function getMovement(sx, sy) {
            return {
              x: sx - focusedElement.screenX,
              y: sy - focusedElement.screenY
            };
          }

          function move(sx, sy) {
            const movement = getMovement(sx, sy);
            const tx = focusedElement.translateX + movement.x;
            const ty = focusedElement.translateY + movement.y;
            focusedElement.DOM.style.transform = `translate(${tx}px, ${ty}px)`;
          }

          function resize(sx, sy) {
            const movement = getMovement(sx, sy);
            let tx = focusedElement.translateX;
            let ty = focusedElement.translateY;
            let width = focusedElement.width;
            let height = focusedElement.height;

            switch (userAction) {
              case 'RESIZE-LT':
                width = focusedElement.width - movement.x;
                height = focusedElement.height - movement.y;
                tx = focusedElement.translateX + movement.x;
                ty = focusedElement.translateY + movement.y;
                break;
              case 'RESIZE-RT':
                width = focusedElement.width + movement.x;
                height = focusedElement.height - movement.y;
                ty = focusedElement.translateY + movement.y;
                break;
              case 'RESIZE-LB':
                width = focusedElement.width - movement.x;
                height = focusedElement.height + movement.y;
                tx = focusedElement.translateX + movement.x;
                break;
              case 'RESIZE-RB':
                width = focusedElement.width + movement.x;
                height = focusedElement.height + movement.y;
                break;
            }

            width = Math.max(50, width);
            height  = Math.max(50, height);

            focusedElement.DOM.style.transform = `translate(${tx}px, ${ty}px)`;
            focusedElement.DOM.style.width = `${width}px`;
            focusedElement.DOM.style.height = `${height}px`;
          }

          function onMouseDown(e) {
            if (e.target && e.target.dataset && e.target.dataset.userAction) {
              let tx = 0;
              let ty = 0;
              const transform = e.target.parentNode.style.transform;
              const matchTranslate = transform.match(/translate\((-?\d+.?\d*)px ?, ?(-?\d+.?\d*)px\)/);
              if (matchTranslate) {
                tx = parseInt(matchTranslate[1]);
                ty = parseInt(matchTranslate[2]);
              }

              focusElement(
                e.target.parentNode,
                parseInt(e.target.parentNode.style.width),
                parseInt(e.target.parentNode.style.height),
                e.screenX,
                e.screenY,
                tx,
                ty
              );

              userAction = e.target.dataset.userAction;
            }
          }

          function onMouseUp(e) {
            blurElement();

            userAction = null;
          }

          function onMouseMove(e) {
            switch (userAction) {
              case 'MOVE':
                move(e.screenX, e.screenY);
                break;
              case 'RESIZE-LT':
              case 'RESIZE-RT':
              case 'RESIZE-LB':
              case 'RESIZE-RB':
                resize(e.screenX, e.screenY);
                break;
            }
          }

          return {
            create: function(x, y, width, height) {
              const div = document.createElement('div');
              div.setAttribute('style', `position:absolute; width:${width}px; height:${height}px; transform: translate(${x}px, ${y}px)`);
              div.innerHTML = `<div data-user-action="MOVE" class="plate"></div>
                <span data-user-action="RESIZE-LT" class="edge lt"></span>
                <span data-user-action="RESIZE-RT" class="edge rt"></span>
                <span data-user-action="RESIZE-LB" class="edge lb"></span>
                <span data-user-action="RESIZE-RB" class="edge rb"></span>`;
              document.body.appendChild(div);
            },
            onMouseDownListener: onMouseDown,
            onMouseUpListener: onMouseUp,
            onMouseMoveListener: onMouseMove,
          }
        })();

        document.addEventListener('DOMContentLoaded', function() {
          Square.create(300, 300, 100, 200);
          Square.create(200, 100, 80, 80);
          document.addEventListener('mousedown', Square.onMouseDownListener);
          document.addEventListener('mouseup', Square.onMouseUpListener);
          document.addEventListener('mousemove', Square.onMouseMoveListener);
        });
      </script>
    </body>
  </html>

\$\endgroup\$

1 Answer 1

4
\$\begingroup\$

class syntax

In JS class is a syntax for creating objects. It is not an independent distinct entity. Class creates an object adding a constructor function and assigning functions and (optionally) properties to the associated prototype. It does not provide any features not available using standard syntax.

The 'class` syntax does suffer from one serious flaw (that in my book makes it unusable). It lacks a mechanism to create private functions and properties. Objects created via the class syntax can not adequately encapsulate an objects state, an essential requirement of OO design.

Modules

In modern JS module, modules, modular, etc... refers to code that is import/export able.

Modules provide a local and isolated scope (state) that does provide some encapsulation for single instance objects created with the class syntax. The same can be achieved with the IIS style and class syntax.

Objects

Your code is a IIF, or IIFE meaning Immediately Invoked Function Expression const obj=(() => ({foo: "bar", log() {console.log(this.foo)}}))() It provides the best encapsulation mechanism for single instance objects (such as your example)

A variation is an object factory function obj() { return {foo: "bar", log() {console.log(this.foo)}} } best suited to long lived multi instance objects.

For performance and many (100 to 1000+) instances of short lived objects, the prototype model is best suited. function Obj() { this.foo = "bar" }; Obj.prototype = { log() {console.log(this.foo)}}; However the encapsulation model is not as strong.

Modern JS engines cache compiled code, thus you can define the prototype inside the function with only a minor memory penalty (prototyped properties and functions each need a reference per object) This lets you use closure to encapsulate state and still provide prototypal inheritance. However I would argue that in JS polymorphisum is the preferred method of extension for JS objects (though not everyone would agree).

There are many more ways to define and instanciate objects. Which you use is defined by how the object is to be used, and what your preferred style is.

Reviewing your code

  • The mouse event provides delta mouse position in MouseEvent.movementX and MouseEvent.movementY so you don't need to function getMovement

  • You should not expose the mouse events for Square. Have Square.create add the events if needed.

  • You can simplify the resize and move logic by treating the corners as part of a side (top, bottom, left, and right) then you move only a side, left or right, top or bottom. Using a bitfield to define corners you can then specify which sides to move for each corner. (see example)

  • It is best not to add markup to the page. Use the DOM API to manipulate the DOM. I find the API somewhat awkward so use some helper functions to improve readability and reduce code noise. (see example /* DOM helpers */)

  • Rather than inspecting the DOM element to get its position and size, assign your focusElement to each size-able element as you create it. Use a Map to make the association. You can then just get that object using the element as key on mouse down (see example) Note that I assume your example is self contained and that there is no manipulation of relevant elements outside the Square object

  • You update the DOM elements position in two places. Use a single function to update the position (and size) of the element.

  • To reduce code complexity use the MouseEvent.type property to determine the event type and handle all similar mouse events in one function.

  • Mouse events are out of sync with the display refresh. For the cleanest updates you should be using requestAnimationFrame to update elements. requestAnimationFrame ensures that backbuffers associated with elements that are rendered from within the callback are only presented during the displays vertical sync.

Example

Uses same IIF style to encapsulate the object (renamed Squares as it can represent many)

Removed the 3 exposed onMouse... functions.

The aim was to reduce source size and improve efficiency.

Creates a boxDesc for each box mapped to the element, rather than updating the one focusElement object.

I did not include the use of requestAnimationFrame

   const Squares = (() => {
    /* Privates */
    var addMouseEvents = true, focus;            
    const boxMap = new Map();
    const corners = { // bit fields 
        none: 0,   // 0b0000
        top: 1,    // 0b0001
        bottom: 2, // 0b0010
        left: 4,   // 0b0100
        right: 8,  // 0b1000
    };
    const MIN_SIZE = 50;
    const HANDLE_SIZE = 8; // adds 2 px

    /* DOM helpers */
    const tag = (name, props ={}) => Object.assign(document.createElement(name), props);
    const elStyle = (el, style) => (Object.assign(el.style, style), el);
    const elData = (el, data) => (Object.assign(el.dataset, data), el);
    const append = (par, ...sibs) => {
        for(const sib of sibs) { par.appendChild(sib) }
        return par;
    };

    const boxDesc = (DOM, width, height, x, y) => ({ DOM, width, height, x, y });
    function update(box) {
        box.width = box.width < MIN_SIZE ? MIN_SIZE : box.width;
        box.height = box.height < MIN_SIZE ? MIN_SIZE : box.height;
        const right = innerWidth - (box.width + HANDLE_SIZE);
        const bot = innerHeight - (box.height + HANDLE_SIZE);
        box.x = box.x < HANDLE_SIZE ? HANDLE_SIZE : box.x > right ? right : box.x;
        box.y = box.y < HANDLE_SIZE ? HANDLE_SIZE : box.y > bot ? bot : box.y;
        elStyle(box.DOM, {
            transform: `translate(${box.x}px, ${box.y}px)`,
            width: box.width + "px",
            height: box.height + "px",
        });
        return box;
    }
    function move(box, dx, dy) {
        const bot = innerHeight - HANDLE_SIZE;
        const right = innerWidth - HANDLE_SIZE;
        if (box.action === corners.none) {
            box.x += dx;
            box.y += dy;
        } else {
            if ((box.action & corners.bottom) === corners.bottom) { 
                box.height = box.y + box.height + dy > bot ? bot - box.y : box.height + dy;
            } else {
                if (box.height - dy < MIN_SIZE) { dy = box.height - MIN_SIZE }
                if (box.y + dy < HANDLE_SIZE) { dy = HANDLE_SIZE - box.y }
                box.y += dy;
                box.height -= dy;
            }
            if ((box.action & corners.right) === corners.right) { 
                box.width = box.x + box.width + dx > right ? right - box.x : box.width + dx;
            } else {
                if (box.width - dx < MIN_SIZE) { dx = box.width - MIN_SIZE }
                if (box.x + dx < HANDLE_SIZE) { dx = HANDLE_SIZE - box.x }
                box.x += dx;
                box.width -= dx;
            }
        }
        update(box);
    }
    function mouseEvent(e) {
        if(e.type === "mousedown") {
            if (e.target.dataset && e.target.dataset.userAction) {
                focus = boxMap.get(e.target.parentNode);
                focus.action = Number(e.target.dataset.userAction);
            }
        }else if(e.type === "mouseup") { focus = undefined }
        else {
            if (e.buttons === 0) { focus = undefined } // to stop sticky button in snippet
            if (focus) { move(focus, e.movementX,  e.movementY) }
        }
    }
    function mouseEvents() {
        document.addEventListener('mousedown', mouseEvent);
        document.addEventListener('mouseup', mouseEvent);
        document.addEventListener('mousemove', mouseEvent);                    
        addMouseEvents = false;                
    }
    return {
        create(x, y, width, height) {
            const box = append( 
                elStyle(tag("div"), { position: "absolute" }),
                elData(tag("div", {className : "plate"}), {userAction : corners.none}),
                elData(tag("span", {className : "edge lt"}), {userAction : corners.top + corners.left}),
                elData(tag("span", {className : "edge rt"}), {userAction : corners.top + corners.right}),
                elData(tag("span", {className : "edge lb"}), {userAction : corners.bottom + corners.left}),
                elData(tag("span", {className : "edge rb"}), {userAction : corners.bottom + corners.right})
            );
            boxMap.set(box, update(boxDesc(box, width, height, x, y))); // update() sizes and positions elements
            append(document.body, box);
            if (addMouseEvents) { mouseEvents() }
        }
    };

})();

document.addEventListener('DOMContentLoaded', function() {
    Squares.create(10, 100, 80, 80);
    Squares.create(110, 100, 80, 80);
    Squares.create(210, 100, 80, 80);
    Squares.create(310, 100, 80, 80);
    Squares.create(410, 100, 80, 80);
});
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  user-select: none;    
  -moz-user-select: none;    
}
.plate {
  position: absolute;
  width: 100%;
  height: 100%;
  background: rgb(240, 240, 240);
  cursor: move;
}
.plate:hover {
  background: #DEE;
}
.edge {
  position: absolute;
  width: 12px;
  height: 12px;
  border-radius: 6px;
  background: rgb(211, 211, 211);
}
.edge:hover {
  background: #CDD;
}
.edge.lt {
  top: -6px;
  left: -6px;
  cursor: nwse-resize;
}
.edge.rt {
  top: -6px;
  right: -6px;
  cursor: nesw-resize;
}
.edge.lb {
  bottom: -6px;
  left: -6px;
  cursor: nesw-resize;
}
.edge.rb {
  bottom: -6px;
  right: -6px;
  cursor: nwse-resize;
}

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

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