Source: pd-sticker.js

/**
* Creates sticker elements along-side pre-defined DOM element(s)
*
*
* @extends HTMLElement
* 
* @author Peter G. Darmis <pdarmis@gmail.com>
* @license MIT
*/
export class pdSticker extends HTMLElement {
  /**
  * Define what are the elements observed attributes, custom or non-custom.
  * .
  * @return {void}
  */
  static get observedAttributes() {
    return ['direction', 'search-for'];
  }
  
  /**
  * The element's constructor. Some basic properties are initialized here.
  * .
  * @return {void}
  */
  constructor() {
    super();
    this.sRoot = this.attachShadow({
      mode: 'closed'
    });
    let direction = this.getAttribute("direction");
    let searchFor = this.getAttribute("search-for");
    this.direction = !this.isEmpty(direction) ? direction : 'left';
    this.searchFor = !this.isEmpty(searchFor) ? searchFor : 'article';
	this.stickers = [];
  }
  
  /**
  * Add a button based on pd-indicator custom web component. 
  * This means that pd-indicator must be imported and that a data-button attribute has been placed in your HTML.
  * e.g. data-button="pd-indicator-switch,pd-indicator-heart,pd-indicator-circle,pd-indicator-octastar,pd-indicator-yin-yang"
  *
  * @param {string} button - A comma separated list of pd-indicator styles.
  * @param {HTMLDivElement} div - An HTML <div> element that the pd-indicator button will be placed.
  * @return {void}
  */
  addIndicator(button, div) {
    let regex = /pd-indicator-/g;
    if (regex.test(button)) {
      let type = button.replace("pd-indicator-", "");
      if (['moon', 'yin-yang', 'pointer', 'rectangle', 'circle', 'pacman', 'octastar', 'infinity', 'heart', 'switch', 'donut'].includes(type)) {
		if (window.customElements.get('pd-indicator')) {
			let indicator = new pdIndicator();
			indicator.setAttribute('type', type);
			div.appendChild(indicator);
		}
      }
    }
  }
  
  /**
  * Changes the direction of the stickers' side. It is either "left" or "right". 
  *
  * @return {void}
  */  
  changeDirection() {
    let self = this;
	let searchFor = document.querySelector(self.searchFor);
	let initialOffsetTop = 2*Math.max(searchFor.parentElement.offsetTop,searchFor.offsetParent.offsetTop);
	let initialOffsetLeft = Math.max(searchFor.parentElement.offsetLeft,searchFor.offsetParent.offsetLeft);
	if(self.isEmpty(initialOffsetTop)) {
	    initialOffsetTop = 0;
	}
	if(self.isEmpty(initialOffsetLeft)) {
	    initialOffsetLeft = 0;
	}
	window.scrollTo(initialOffsetTop,400);
    let items = document.querySelectorAll(self.searchFor);
	let scroll_distance = [];
	self.sRoot.querySelectorAll("div.sticker").forEach((sticker, index) => {
		self.applyPosition(sticker, items[index], index, scroll_distance, initialOffsetLeft, initialOffsetTop);
	});
  }

  /**
  * Initialize the module. After the module instance has been created, here is were most work is done.
  * Styling will be applied.
  * All stickers will be created and displayed at the proper position. 
  * At the same time the scroll distance of each sticker will have been calculated. 
  *
  * @return {void}
  */   
  init() {
    let self = this;
	let searchFor = document.querySelector(self.searchFor);
	let initialOffsetTop = 2*Math.max(searchFor.parentElement.offsetTop,searchFor.offsetParent.offsetTop);
	let initialOffsetLeft = Math.max(searchFor.parentElement.offsetLeft,searchFor.offsetParent.offsetLeft);
	if(self.isEmpty(initialOffsetTop)) {
	    initialOffsetTop = 0;
	}
	if(self.isEmpty(initialOffsetLeft)) {
	    initialOffsetLeft = 0;
	}
	window.scrollTo(initialOffsetTop,400);
    let scroll_distance = [];
	if (!self.isEmpty(self.sRoot.querySelector("style"))) {
        self.sRoot.querySelector("style").remove();
    }
    if (!self.isEmpty(self.sRoot.querySelector("div.sticker"))) {
        self.sRoot.querySelectorAll("div.sticker").forEach(item => item.remove());
    }
	if (self.isEmpty(self.sRoot.querySelector("style"))) {
		let style = document.createElement("style");
		style.innerHTML = "pd-indicator, a, button { cursor: pointer; } .sticker img, .sticker video { width: 3rem; height: auto; } .sticker { font-size: 2vmin; } .note { text-decoration: none; background: #ffc; box-shadow: .375rem .375rem .5rem rgba(33,33,33,.7); } .note.rotatedLeft { transform: rotate(-6deg); } .note.rotatedRight { transform: rotate(6deg); } .speech-bubble-left, .speech-bubble-right { backdrop-filter: blur(0.25rem); background: rgba(4, 17, 32, 1); box-shadow: .375rem .375rem .5rem rgba(33,33,33,.7); border-radius: .5rem } .speech-bubble-left:before { content: \"\"; position: absolute; right: 100%; top: 1.5rem; width: 0; height: 0; border-top: .5rem solid transparent; border-right: 1rem solid rgba(4, 17, 32, 1); border-bottom: .5rem solid transparent; }  .speech-bubble-right:after { content: \"\"; position: absolute; left: 100%; top: 1.5rem; width: 0; height: 0; border-top: .5rem solid transparent; border-left: 1rem solid rgba(4, 17, 32, 1); border-bottom: .5rem solid transparent; }.speech-bubble-left, .speech-bubble-left *, .speech-bubble-right, .speech-bubble-right * { color: #ffffff; } .black-box { color: #ffffff; padding: .125rem; list-style: none; box-shadow: .375rem .375rem .5rem rgba(33,33,33,.7); background: rgba(4, 18, 27, 0.88); border-radius: .25rem; } .speech-bubble-left, .speech-bubble-right, .black-box { padding: 0.5rem; }";
		self.sRoot.appendChild(style);
	}
    document.querySelectorAll(self.searchFor).forEach((item, index) => { 
	  let id = !self.isEmpty(item.id) ? item.id : index ;
      let div = document.createElement("div");
      div.id = "sticker-" + id;
      div.classList.add("sticker");
      div.style.marginTop = 0;
      div.style.maxWidth = '8rem';
	  div.style.width = 'auto';
      div.style.minHeight = '5rem';
	  div.style.height = 'auto';
	  div.style.textAlign = 'center';
	  div.style.flexDirection = 'column';
	  div.style.alignItems = 'center';
	  div.style.alignContent = 'center';
	  div.style.justifyContent = 'center';
	  div.style.display = 'none';
      if (item.title) {
        let regex = /(<([^>]+)>)/ig;
        item.title = item.title.replace(regex, "");
        if (item.dataset.link) {
          div.innerHTML = '<a href="' + item.dataset.link + '">' + item.title + '</a>';
        } else {
          div.innerHTML = item.title;
        }
      }
      if (item.dataset.img) {
        if (item.dataset.link) {
          div.innerHTML = '<a href="' + item.dataset.link + '"><img src="' + item.dataset.img + '"/></a>';
        } else {
          div.innerHTML = '<img src="' + item.dataset.img + '"/>';
        }
      }
      if (item.dataset.button) {
        let buttons = item.dataset.button;
        if (item.dataset.button.indexOf(",") !== -1) {
          buttons = item.dataset.button.split(",").filter((word) => {
            return word.length > 0;
			});
        }
        if (Array.isArray(buttons)) {
          buttons.forEach((button) => {
            self.addIndicator(button, div);
          })
        } else {
          self.addIndicator(buttons, div);
        }
      }
      div.style.position = "absolute";
	  div.style.top = (initialOffsetTop/ 16) + 'rem';      
	  let slot = document.createElement("slot");
	  slot.name = "sticker-"+index;
	  div.append(slot);
      self.sRoot.append(div);
      let sticker = self.sRoot.querySelector("#sticker-" + id);
	  sticker.style.display = 'flex';
	  self.applyPosition(sticker, item, index, scroll_distance, initialOffsetLeft, initialOffsetTop);
    });
  }

  /**
  * This is a function that acts as a helper function to changeDirection() and init().
  * Styling will be applied.
  * All stickers will be created and displayed at the proper position. 
  * At the same time the scroll distance of each sticker will have been calculated. 
  *
  * @param {HTMLDivElement} sticker - An <pd-sticker> HTML <div> sub-element that is the sticker object displayed at the front-end.
  * @param {HTMLElement} item - An HTML element that the associated sticker object created by pd-sticker will be placed.
  * @param {number} index - The index of the item HTML element above.
  * @param {array} scroll_distance - An empty array that will be filled with the values of available scroll distance for each sticker object.
  * @param {number} initialOffsetLeft - The initial left offset of each sticker object.
  * @param {number} initialOffsetTop - The initial top offset of each sticker object.
  * @return {void}
  */   
  applyPosition(sticker, item, index, scroll_distance, initialOffsetLeft, initialOffsetTop) {
	let self = this;
	scroll_distance[index] = item.offsetHeight - sticker.offsetHeight;
	if (self.direction == 'right') {
		sticker.style.left = (100*(window.innerWidth - (item.offsetLeft + initialOffsetLeft)) / window.innerWidth) + '%';
	} else {
		sticker.style.left = (100*(initialOffsetLeft + parseFloat(window.getComputedStyle(item, null).getPropertyValue("padding-left")) - parseFloat(window.getComputedStyle(sticker, null).getPropertyValue("width")))/window.innerWidth) + '%';
	}
	if (!self.isEmpty(item.hasChildNodes())) {
	let children = item.childNodes;		
		if (children.length > 1) {
		  for (let i = 0; i < children.length; i++) {
			if (!self.isEmpty(children[i].tagName)) {
			  children[i].addEventListener("load", (e) => {
				scroll_distance[index] += !self.isEmpty(children[i].clientHeight) ? children[i].clientHeight - sticker.clientHeight : 0;
				self.duringScroll(sticker, item, scroll_distance[index], initialOffsetTop);
			  });
			}
		  }
		  self.duringScroll(sticker, item, scroll_distance[index], initialOffsetTop);
		} else {
		  self.duringScroll(sticker, item, scroll_distance[index], initialOffsetTop);
		}
	} else {
	self.duringScroll(sticker, item, scroll_distance[index], initialOffsetTop);
	}
  }

  /**
  * This is a function that acts as a helper function to applyPosition().
  * It helps on smoother animation of the stickers' movement while scrolling.
  * It also manages the behavior of each sticker. If it has an absolute position or fixed while scrolling. 
  *
  * @param {HTMLDivElement} sticker - An <pd-sticker> HTML <div> sub-element that is the sticker object displayed at the front-end.
  * @param {HTMLElement} item - An HTML element that the associated sticker object created by pd-sticker will be placed.
  * @param {array} scroll_distance - An array containing the values of available scroll distance for each sticker object.
  * @param {number} initialOffsetTop - The initial top offset of each sticker object.
  * @return {void}
  */   
  duringScroll(sticker, item, scroll_distance, initialOffsetTop = 0) {
	let scroll = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function(callback) {
      window.setTimeout(callback, 1000 / 72)
    };
    window.addEventListener("scroll", () => { 
      scroll(() => {
        if (document.documentElement.scrollTop >= (item.offsetTop + initialOffsetTop) && document.documentElement.scrollTop <= (item.offsetTop + scroll_distance + initialOffsetTop)) {
          sticker.style.position = 'fixed';
		  sticker.style.top = '0.5rem';
        } else {
          sticker.style.position = 'absolute';
          if (document.documentElement.scrollTop > (item.offsetTop + initialOffsetTop)) {
		    sticker.style.top = ((item.offsetTop + initialOffsetTop + scroll_distance) / 16) + 'rem';
          } else {
		    sticker.style.top = ((item.offsetTop + initialOffsetTop) / 16) + 'rem';
          }
        }
      });
    });
  }

  /**
  * This is a function that acts as a helper function in general.
  * It checks if a value is "empty" in case of many types of input. 
  *
  * @param {?(number|boolean|array|object|string)} value - An multiple type value to be checked.
  * @return {boolean}
  */   
  isEmpty(value) {
    switch (true) {
      case (value == null || value == undefined):
        return true;
      case (Array.isArray(value)):
        return value.length == 0;
      case (typeof value == 'object'):
        return (Object.keys(value).length === 0 && value.constructor === Object);
      case (typeof value == 'string'):
        return value.length == 0;
      case (typeof value == 'number' && !isNaN(value)):
        return value == 0;
      case (!value):
        return true;
      default:
        return false;
    }
  }

  /**
  * Invoked each time the custom element is appended into a document-connected element. 
  * This will happen each time the node is moved, and may happen before the element's contents have been fully parsed. 
  *
  * @return {void}
  */   
  connectedCallback() {
    let direction = this.getAttribute("direction");
    let searchFor = this.getAttribute("search-for");
    this.direction = !this.isEmpty(direction) ? direction : 'left';
    this.searchFor = !this.isEmpty(searchFor) ? searchFor : 'article'; 
	this.init();
	window.onresize = () =>	{
		this.changeDirection(); 
	}
	window.onorientationchange = () =>	{
		this.changeDirection(); 
	}
  }

  /**
  * Invoked each time the custom element is disconnected from the document's DOM.
  *
  * @return {void}
  */   
  disconnectedCallback() {
    console.log('Disconnected.');
  }

  /**
  * Invoked each time the custom element is moved to a new document. 
  *
  * @return {void}
  */   
  adoptedCallback() {
    console.log('Adopted.');
  }

  /**
  * Invoked each time one of the custom element's attributes is added, removed, or changed. 
  * Which attributes to notice change for is specified in a static getobservedAttributes() method
  *
  * @param {string} name - The element's attribute name.
  * @param {string} oldValue - The old attribute value.
  * @param {string} newValue - The new attribute value.
  * @return {void}
  */   
  attributeChangedCallback(name, oldValue, newValue) {
    if (name == "direction") {
      let direction = this.getAttribute("direction");
      this.direction = !this.isEmpty(direction) ? direction : 'left';
	  this.changeDirection();
    }
    if (name == "search-for") {
	  let searchFor = this.getAttribute("search-for");
      this.searchFor = !this.isEmpty(searchFor) ? searchFor : 'article';
    }
  }
  
}

if (!window.customElements.get('pd-sticker')) {
  window.pdSticker = pdSticker;
  window.customElements.define("pd-sticker", pdSticker);
}