DEV Community

Cover image for Creating a Performant Rectangle Selection Box with Callbacks Using Web Components
Pratik sharma
Pratik sharma

Posted on • Originally published at blog.coolhead.in

1 1 1 1 1

Creating a Performant Rectangle Selection Box with Callbacks Using Web Components

In this article, you'll learn how to create a performant rectangle selection box using web components and JavaScript. The objective is to build an interactive tool that allows users to select multiple elements within a defined rectangular area, with a callback function that provides all the elements within the selection.

Let's break down the implementation into small, manageable problems.

1. Getting DOM Elements within a Rectangle

To identify all elements within the selection rectangle, we need to check if the rectangle boundaries intersect with the bounding boxes of the elements on the page.

getElementsWithinSelection(rect) {
  const allElements = Array.from(document.body.querySelectorAll('*'));
  return allElements.filter(element => {
    const elRect = element.getBoundingClientRect();
    return (
      elRect.top < rect.bottom &&
      elRect.bottom > rect.top &&
      elRect.left < rect.right &&
      elRect.right > rect.left
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

How It Works:

  • getBoundingClientRect() gets the position and dimensions of an element.

  • We filter the elements whose bounding boxes intersect with the given rectangle.

2. Drawing/Rendering the Selection UI

To visually represent the selection area, we'll create an overlay element styled with a transparent background.

<style>
  :host {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 9999;
    pointer-events: none;
  }
  .selection-overlay {
    position: absolute;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 255, 0.2);
    clip-path: polygon(0 0, 0 0, 0 0, 0 0);
    opacity: 0;
  }
</style>
<div class="selection-overlay"></div>
Enter fullscreen mode Exit fullscreen mode

3. Creating a Component Using Web Components

Web components encapsulate the functionality and styling, making the selection tool reusable and modular.

Constructor

constructor() {
  super();
  this.attachShadow({ mode: 'open' });

  this.startX = 0;
  this.startY = 0;
  this.isSelecting = false;

  this.shadowRoot.innerHTML = `
  <style>
    :host {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      z-index: 9999;
      pointer-events: none;
    }
    .selection-overlay {
      position: absolute;
      width: 100%;
      height: 100%;
      background: rgba(0, 0, 255, 0.2);
      clip-path: polygon(0 0, 0 0, 0 0, 0 0);
      opacity: 0;
    }
  </style>
  <div class="selection-overlay"></div>
`;

  this.overlay = this.shadowRoot.querySelector('.selection-overlay');
}
Enter fullscreen mode Exit fullscreen mode

Connecting and Disconnecting Event Listeners

We add mouse event listeners to capture user interactions.

connectedCallback() {
  this.addEventListeners();
}

disconnectedCallback() {
  this.removeEventListeners();
}

addEventListeners() {
  document.addEventListener('mousedown', (event) => this.onMouseDown(event));
  document.addEventListener('mouseup', (event) => this.onMouseUp(event));
  document.addEventListener('mousemove', (event) => this.onMouseMove(event));
  document.addEventListener('touchstart', (event) => this.onTouchStart(event));
  document.addEventListener('touchend', (event) => this.onTouchEnd(event));
  document.addEventListener('touchmove', (event) => this.onTouchMove(event));
}

removeEventListeners() {
  document.removeEventListener('mousedown', this.onMouseDown);
  document.removeEventListener('mouseup', this.onMouseUp);
  document.removeEventListener('mousemove', this.onMouseMove);
  document.removeEventListener('touchstart', this.onTouchStart);
  document.removeEventListener('touchend', this.onTouchEnd);
  document.removeEventListener('touchmove', this.onTouchMove);
}
Enter fullscreen mode Exit fullscreen mode

4. Adding Props/Attributes to Web Components

We can define attributes that control the behavior or appearance of the component. In this case, let's support a customizable background.

static get observedAttributes() {
  return ['background'];
}

attributeChangedCallback(name, oldValue, newValue) {
  if (name === 'background') {
    this.setBackground(newValue);
  }
}

setBackground(background) {
  this.overlay.style.background = background;
}
Enter fullscreen mode Exit fullscreen mode

5. Propagating Custom Events with Callbacks

When the selection is complete, dispatch a custom event with the selected elements.

this.dispatchEvent(new CustomEvent('selection-complete', {
  detail: { elements: elementsWithinSelection },
  bubbles: true,
  composed: true,
}));
Enter fullscreen mode Exit fullscreen mode

6. Using the Component and Listening for Callbacks

To use the custom selection tool component and handle the callback, add the following code:

<cr-selection id="selection-tool" background="rgba(255, 0, 0, 0.1)"></cr-selection>
Enter fullscreen mode Exit fullscreen mode
const selectionTool = document.getElementById('selection-tool');

// Listen for the selection-complete event
selectionTool.addEventListener('selection-complete', (event) => {
  const selectedElements = event.detail.elements;

  // Log the selected elements
  console.log('Selected Elements:', selectedElements);

  // Highlight the selected elements
  selectedElements.forEach(el => {
    el.style.border = '2px solid red';
  });
});
Enter fullscreen mode Exit fullscreen mode

How It Works:

  1. The component listens for mouse events to define the selection rectangle.

  2. When the selection completes, the selection-complete event fires with the selected elements.

  3. The callback function handles highlighting the selected elements.

Full Javascript Code

class CrSelection extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });

        this.startX = 0;
        this.startY = 0;
        this.isSelecting = false;

        this.shadowRoot.innerHTML = `
        <style>
          :host {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 9999;
            pointer-events: none;
          }
          .selection-overlay {
            position: absolute;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 255, 0.2);
            clip-path: polygon(0 0, 0 0, 0 0, 0 0);
            opacity: 0;
          }
        </style>
        <div class="selection-overlay"></div>
      `;

        this.overlay = this.shadowRoot.querySelector('.selection-overlay');
    }

    static get observedAttributes() {
        return ['background']
    }

    attributeChangedCallback(name, oldValue, newValue) {
        if (name === 'background') {
            this.setBackground(newValue);
        }
    }

    setBackground(background) {
        this.overlay.style.background = background;
    }



    connectedCallback() {
        this.addEventListeners();
    }

    disconnectedCallback() {
        this.removeEventListeners();
    }

    addEventListeners() {
        document.addEventListener('mousedown', (event) => this.onMouseDown(event));
        document.addEventListener('mouseup', (event) => this.onMouseUp(event));
        document.addEventListener('mousemove', (event) => this.onMouseMove(event));
   document.addEventListener('touchstart', (event) => this.onTouchStart(event));
        document.addEventListener('touchend', (event) => this.onTouchEnd(event));
        document.addEventListener('touchmove', (event) => this.onTouchMove(event));
    }

    removeEventListeners() {
        document.removeEventListener('mousedown', this.onMouseDown);
        document.removeEventListener('mouseup', this.onMouseUp);
        document.removeEventListener('mousemove', this.onMouseMove);
 document.removeEventListener('touchstart', this.onTouchStart);
        document.removeEventListener('touchend', this.onTouchEnd);
        document.removeEventListener('touchmove', this.onTouchMove);
    }

  onTouchStart(event) {
      console.log("mobile touch start", event.touches[0])
        this.onMouseDown(event.touches[0]);
    }

    onTouchEnd(event) {
            console.log("mobile touch end", event.changedTouches[0]);

        this.onMouseUp(event.changedTouches[0]);
    }

    onTouchMove(event) {
                  console.log("mobile touch move", event.touches[0]);

        this.onMouseMove(event.touches[0]);
    }

    onMouseDown(event) {

        this.startX = event.clientX;
        this.startY = event.clientY;
        this.isSelecting = true;

        this.overlay.style.clipPath = 'none'; // Reset the clip-path
    }

    onMouseMove(event) {
        if (!this.isSelecting) return;

        const currentX = event.clientX;
        const currentY = event.clientY;

        const x1 = Math.min(this.startX, currentX);
        const y1 = Math.min(this.startY, currentY);
        const x2 = Math.max(this.startX, currentX);
        const y2 = Math.max(this.startY, currentY);
        this.overlay.style.opacity = '100'


        this.overlay.style.clipPath = `
            polygon(
                ${x1}px ${y1}px, 
                ${x2}px ${y1}px, 
                ${x2}px ${y2}px, 
                ${x1}px ${y2}px
            )
        `;
    }

    onMouseUp(event) {
        if (!this.isSelecting) return;

        this.isSelecting = false;

        const selectionRect = {
            top: Math.min(this.startY, event.clientY),
            left: Math.min(this.startX, event.clientX),
            right: Math.max(this.startX, event.clientX),
            bottom: Math.max(this.startY, event.clientY),
        };

        const elementsWithinSelection = this.getElementsWithinSelection(selectionRect);

        this.dispatchEvent(new CustomEvent('selection-complete', {
            detail: { elements: elementsWithinSelection },
            bubbles: true,
            composed: true,
        }));

        console.log('elements in selection', elementsWithinSelection);

        this.overlay.style.clipPath = 'none'; // Clear selection area
        this.overlay.style.opacity = '0'
    }

    getElementsWithinSelection(rect) {
        const allElements = Array.from(document.body.querySelectorAll('*'));

        return allElements.filter(element => {
            if (element === this || this.contains(element)) {
                return false;
            }
            const elRect = element.getBoundingClientRect();
            return (
                elRect.top < rect.bottom &&
                elRect.bottom > rect.top &&
                elRect.left < rect.right &&
                elRect.right > rect.left
            );
        });
    }
}

customElements.define('cr-selection', CrSelection);
Enter fullscreen mode Exit fullscreen mode

Codepen

Here the Full Code in Codepen :

Try Click and Dragging in the above Codepen

Conclusion

With this approach, we've built a performant and customizable rectangle selection box using web components. This tool can be extended for various applications such as graphic editors, data selection, or visual analysis tools. The modular design ensures it's reusable and maintainable for future projects.

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (2)

Collapse
 
joelbonetr profile image
JoelBonetR 🥇

It doesn't work on smartphone, sadly... 🙃 Do you think you can make it work?

Collapse
 
biomathcode profile image
Pratik sharma • Edited

You just need to add event listener for touch types developer.mozilla.org/en-US/docs/W...

I have added support for Mobile devices as well

AWS Q Developer image

Your AI Code Assistant

Automate your code reviews. Catch bugs before your coworkers. Fix security issues in your code. Built to handle large projects, Amazon Q Developer works alongside you from idea to production code.

Get started free in your IDE

👋 Kindness is contagious

DEV is better (more customized, reading settings like dark mode etc) when you're signed in!

Okay