React slow event firing on vanilla mouseover event - javascript

I have the following code that highlights all the elements that have the same 4 classes, position 0,1 3 and 4.
I have written it in vanilla js and React, the vanilla version works as expected but the react implementation works in a weird way.
Sometimes works, sometimes doesn't, it's like the onExit event is not being called at the right time but just a little bit after so that the prev variable which stores the last hovered item, has another value and didn't had the chance to remove the highlight class.
It's worth saying that this is being used in a Chrome Extension to highlight items on a webpage so everything that is highlighted is outise the react tree, when someone clicks my extension, the following code registers the event on the active tab.
There is also a onClick event which I didn't include to capture all the elements into a variable and then display some tools (which are React components) showing those elments and for that I need to somehow make the React to properly capture the onExit event on time or to connect the vanilla code to some React components to get the captured elements.
// vanilla script, first one
export const initEvents: any = () => {
let _accountantUtils = function () {
this.currClasses = [];
this.selected = [];
if (this.constructor.instance) return this.constructor.instance;
this.constructor.instance = this;
let prev;
this.initOnHover = () => {
const handler = (event) => {
if (event.target === document.body || (prev && prev === event.target)) {
return;
}
if (prev) {
this.onExit(prev);
prev.className = prev.className.replace(/\bh3-highlight\b/, "");
prev = undefined;
}
if (event.target) {
this.onHover(event.target);
prev = event.target;
prev.className += " h3-highlight";
}
}
if (document.body.addEventListener) {
document.body.addEventListener("mouseover", handler);
} else {
alert("browser not supported");
return;
}
};
this.onHover = function (eventEl) {
let classes = [
eventEl.classList[0],
eventEl.classList[1],
eventEl.classList[3],
eventEl.classList[4],
];
let selector = `.${classes.join(".")}`;
const elsWithClasses = Array.from(document.querySelectorAll(selector));
elsWithClasses.map((el) => el.classList.add("highlight"));
this.currClasses = classes;
};
this.onExit = function (eventEl) {
let classes = [
eventEl.classList[0],
eventEl.classList[1],
eventEl.classList[3],
eventEl.classList[4],
];
let selector = `.${classes.join(".")}`;
const elsWithClasses = Array.from(document.querySelectorAll(selector));
elsWithClasses.map((el) => el.classList.remove("highlight"));
this.currClasses = [];
};
};
const accountantUtils = new _accountantUtils();
return accountantUtils;
}
// then init it this this where it's imported:
const accountantUtils = initEvents();
accountantUtils.initOnHover();
const root = ReactDOM.createRoot(document.querySelector("#h3-app") as HTMLElement);
// React implementation
let prev = null;
export const HoverListener = function () {
useEffect(() => {
initOnHover(onExit, onHover);
}, []);
const initOnHover = function (onHover, onExit) {
if (document.body.addEventListener) {
eventListener = document.body.addEventListener('mouseover', handler);
}
else {
alert("browser not supported")
return;
}
function handler(event) {
if (event.target === document.body ||
(prev && prev === event.target)) {
return;
}
if (prev) {
if (onExit) onExit(prev)
prev.className = prev.className.replace(/\bh3-highlight\b/, '');
prev = undefined;
}
if (event.target) {
if (onHover) onHover(event.target)
prev = event.target;
prev.className += " h3-highlight";
}
}
}
const onExit = function (eventEl) {
let classes = [
eventEl.classList[0],
eventEl.classList[1],
eventEl.classList[3],
eventEl.classList[4],
];
let selector = `.${classes.join(".")}`;
const elsWithClasses = Array.from(document.querySelectorAll(selector));
elsWithClasses.map(el => el.classList.remove("h3-highlight"));
}
const onHover = function (eventEl) {
let classes = [
eventEl.classList[0],
eventEl.classList[1],
eventEl.classList[3],
eventEl.classList[4],
];
let selector = `.${classes.join(".")}`;
const elsWithClasses = Array.from(document.querySelectorAll(selector));
elsWithClasses.map(el => el.classList.add("h3-highlight"));
}
return (
<div>
<h1>Listener...</h1>
</div>
);
}
React implementation
Vanilla code

Related

When a key is pressed, the event occurs twice

When I press a key using keyEvent, then I call the function, it is below.
const GeneratedKey: FC<IGenerated> = (props) => {
const [keyBoard, setKeyBoard] = useState("")
const [arrayMovie, setArrayMovie] = useState<string[]>([])
const idPage = props.idpage
const nameMovie = data.results[idPage].title
const [idLetter, setIdLetter] = useState<number>(0)
const [indexArray, setIndexArray] = useState<number[]>([])
const arrayNameMovie = nameMovie.split(" ").join("").split("");
const getKey = (e: any) => {
console.log("test")
const key = e.key
let count = 0
for (let i = 65; i <= 90; i++) {
if (key.toUpperCase() == String.fromCharCode(i)) {
count = 1
} else if (key.toLowerCase() == "backspace") {
count = 10
}
}
if (count == 1) {
indexArray.sort(function (a: any, b: any) {
return a - b;
})
arrayMovie.splice(indexArray[idLetter], 1, key)
setIdLetter(idLetter + 1)
} else if (count == 10) {
if (idLetter >= 1) {
setIdLetter(idLetter - 1)
arrayMovie.splice(indexArray[idLetter], 1, "")
}
}
setKeyBoard(key)
document.removeEventListener("keydown", getKey);
}
useEffect(() => {
for (let i = 3; i >= 0; i--) {
const randomIndex = Math.floor(Math.random() * arrayNameMovie.length)
arrayNameMovie.splice(randomIndex, 1, " ")
indexArray.push(randomIndex)
}
setIdLetter(indexArray[0])
setArrayMovie(arrayNameMovie)
}, [])
document.addEventListener("keydown", getKey)
return (
<div className="down__word">
{arrayMovie.map((letter: any) =>
<LettersView letters={letter} props={undefined} />
)}
</div>
)
}
In fact, it should be called once, but it fires twice, as you can see from console.log();
How can I fix this, I can also show other files with code, but this is unlikely to help
This is due to your component may get rendered twice (due to change of props or any external reason). Moreover I think this is not the correct way to handle the event listener in FC. You should consider the useEffect for registration / un-registration of event listener.
useEffect(() => {
const handler = () => {};
document.addEventListener('keydown', handler);
return () => {
document.removeEventListener('keydown', handler);
}
}, [deps]); // if you have any changing dependency else just remove the array to execute the UseEffect everytime.
This will ensure that you have registered the event only once in your code.
Refer https://reactjs.org/docs/hooks-effect.html

Problem closing a card modal on JavaScript

I have a card that opens when the user select a specific project and click on it. Then the card opens showing all data. The problem that I'm facing is that when I'm closing the card (clicking on the span "x") the card is removed, but it's created again as soon as the other card has been removed. I know that I need to break the loop when the card is removed, but I'm not being able to fix this problem. What am I missing?
Code:
async function createCard(i) {
div.appendChild(exit);
div.appendChild(title);
div.appendChild(innerDiv);
innerDiv.appendChild(createGifWrapper(i));
innerDiv.appendChild(createDescriptionWrapper(i));
}
function handleCard() {
for (let i = 0; i < cards.length; i++) {
// cards is an Array of objects that contain all data from each project
cards[i].addEventListener('click', listerner = function(e) {
if(document.querySelector('.card')) {
if(document.getElementById(i)) {
console.log('has card and id')
}
} else {
if(this.id === `card-${i+1}`) {
title.textContent = titles[i].innerText;
this.appendChild(div);
div.setAttribute('id', i+1);
createCard(i);
// Exit = Span
exit.addEventListener('click', function() {
console.log(document.getElementById(i+1));
let card = document.getElementById(i+1);
return card.remove();
// Should break here
})
}
}
})
}
}
As nobody has answered me. I found a solution by myself and would like to share if anyone step up on this problem one day.
import { langData } from '../Languages/langData';
export async function portfolio() {
/**
* Data for each card project
*/
let cardData
let defaultText
switch(location.hash) {
case('#pt-br'):
cardData = langData['pt-br'].portfolio.cardData
defaultText = langData['pt-br'].portfolio.defaultTitles
console.log(defaultText)
break;
case('#en'):
cardData = langData['en'].portfolio.cardData
defaultText = langData['en'].portfolio.defaultTitles
break;
}
await console.log('teste')
/**
* Creating card
*/
const cards = document.querySelectorAll('.project-card')
// Exit button
const exit = document.createElement('span')
// Card Title
const titles = document.querySelectorAll('.project-title')
let title = document.createElement('h3')
title.setAttribute('class', 'card-title')
// GIFs
const gifWrapper = document.createElement('div')
const gifMobile = document.createElement('img')
const gifDesktop = document.createElement('img')
// Description
const descriptionTitle = document.createElement('h4')
const description = document.createElement('h5')
const toolsTitle = document.createElement('h4')
const tools = document.createElement('h5')
const descriptionWrapper = document.createElement('div')
// Inner Div - card-details
const innerDiv = document.createElement('div')
let divCard = document.createElement('div')
divCard.setAttribute('class', 'card')
async function destroyCard() {
await exit.remove()
await gifMobile.remove()
await gifDesktop.remove()
await gifWrapper.remove()
await descriptionTitle.remove()
await description.remove()
await toolsTitle.remove()
await tools.remove()
await descriptionWrapper.remove()
await innerDiv.remove()
await divCard.remove()
}
function createExit() {
exit.setAttribute('class', 'card-exit')
exit.textContent = 'x'
exit.addEventListener('click', function() {
destroyCard()
})
return exit
}
function createInnerDiv(i) {
innerDiv.setAttribute('class', 'card-details')
innerDiv.appendChild(createGifWrapper(i))
innerDiv.appendChild(createDescriptionWrapper(i))
return innerDiv
}
// GIF - Return gifWrapper div with gifs mobile and desktop inside it
function createGifWrapper(i) {
gifDesktop.setAttribute('src', cardData[i].gifDesktop)
gifMobile.setAttribute('src', cardData[i].gifMobile)
gifWrapper.setAttribute('class', 'gif-wrapper')
gifWrapper.appendChild(gifDesktop)
gifWrapper.appendChild(gifMobile)
return gifWrapper
}
// Description - return descriptionWrapper div with the description and tools
function createDescriptionWrapper(i) {
// Description
descriptionTitle.textContent = defaultText[0]['DescriptionTitle']
description.setAttribute('class', 'description')
description.textContent = cardData[i].description
// Toools
toolsTitle.textContent = defaultText[0]['Tools']
tools.setAttribute('class', 'description')
tools.textContent = cardData[i].tools
// Wrapper
descriptionWrapper.setAttribute('class', 'card-description')
descriptionWrapper.appendChild(descriptionTitle)
descriptionWrapper.appendChild(description)
descriptionWrapper.appendChild(toolsTitle)
descriptionWrapper.appendChild(tools)
return descriptionWrapper
}
async function createCard(i) {
divCard.appendChild(createExit())
divCard.appendChild(title)
title.textContent = titles[i].innerText
divCard.appendChild(createInnerDiv(i))
divCard.setAttribute('id', i+1)
cards[i].appendChild(divCard)
}
function toggleOpen(i) {
createCard((i.path[2].id.substr(5))-1)
divCard.addEventListener('click', e => e.stopPropagation())
}
for (let i = 0; i < cards.length; i++) {
cards[i].addEventListener('click', toggleOpen)
}
}

Multiple elements -> one function while the elements do not colide with each other

I have multiple selectiontags on different picture slides but same page. Each slide has a set of selectiontags and I want users to only choose 1 selectiontag. I have written the code to do this but I wonder if there is another way.
So, basically I want:
Slide1 w. selectiontags1: Choose 1 selectiontag (out of 4)
Slide2 w.selectiontags2: Choose 1 selectiontag
Slide3 w. selectiontags3: Choose 1 selectiontag
Slide4 w. selectiontags4: Choose 1 selectiontag
This is my code so far.
var prevSelectedValue = null;
var prevSelectedValue2 = null;
var prevSelectedValue3 = null;
var prevSelectedValue4 = null;
$w.onReady(function () {
//TODO: write your page related code here...
let tags = $w('#selectionTags1');
if (tags.value.length === 1) {
prevSelectedValue = tags.value[0];
} else if (tags.value.length > 1) {
tags.value = [];
}
let tags2 = $w('#selectionTags2');
if (tags2.value.length === 1) {
prevSelectedValue2 = tags2.value[0];
} else if (tags2.value.length > 1) {
tags2.value = [];
}
let tags3 = $w('#selectionTags3');
if (tags3.value.length === 1) {
prevSelectedValue3 = tags3.value[0];
} else if (tags3.value.length > 1) {
tags3.value = [];
}
let tags4 = $w('#selectionTags4');
if (tags4.value.length === 1) {
prevSelectedValue4 = tags4.value[0];
} else if (tags4.value.length > 1) {
tags4.value = [];
}
});
export function selectionTags1_change(event) {
//Add your code for this event here:
if (!event.target.value || event.target.value.length === 0) {
event.target.value = [prevSelectedValue];
} else {
event.target.value = event.target.value.filter(x => x !== prevSelectedValue);
prevSelectedValue = event.target.value[0];
}
}
export function selectionTags2_change(event) {
//Add your code for this event here:
if (!event.target.value || event.target.value.length === 0) {
event.target.value = [prevSelectedValue2];
} else {
event.target.value = event.target.value.filter(x => x !== prevSelectedValue2);
prevSelectedValue2 = event.target.value[0];
}
}
export function selectionTags3_change(event) {
//Add your code for this event here:
if (!event.target.value || event.target.value.length === 0) {
event.target.value = [prevSelectedValue3];
} else {
event.target.value = event.target.value.filter(x => x !== prevSelectedValue3);
prevSelectedValue3 = event.target.value[0];
}
}
export function selectionTags4_change(event) {
//Add your code for this event here:
if (!event.target.value || event.target.value.length === 0) {
event.target.value = [prevSelectedValue4];
} else {
event.target.value = event.target.value.filter(x => x !== prevSelectedValue4);
prevSelectedValue4 = event.target.value[0];
}
}
Just a couple notes to help clean up the code:
It is better to use event.target.value[event.target.value.length-1] instead of .filter(). While it doesn't make a huge performance difference since our array is so small, we know that the most recently selected item is the last in the array, so using length-1 is more efficient code.
It is better to manipulate the selector and not the event, so I would recommend replacing event.target.value = event.target.value.filter... with $w('#selectionTags2') = event.target.value[..... This will also make your code more readable as you know which element is being updated.
If you are not preloading anything into the $w('#selectionTags') value array, I am not sure the code in your onReady function is necessary (unless I am missing a step where you do populate that selection value array).

Using a JavaScript function to add data to a string that comes from the HTML document, and preserving that data after that function is called again

I wrote an object called TypeWriter2 and then I want to add it a function called type2().
I then call the TypeWriter2 object using a function called init2(), which queries some data from the html document and passes it to the TypeWriter2 object.
The data that init2() is querying from the html document is:
txtElement2 = a div element, that the type2() function will use to display some data.
words2 = the words that are to be displayed in txtElement2, which is "Hello, there... Yoo"
wait2 = an int that will be passed to setTimeout() later on.
The type2() function is meant is meant of add "iiiiii" to "txt2" (an empty string at the beginning), whenever "txt2" ends with 3 consecutive dots.
The problem being that after "iiiiii" is added to "txt2" and "setTimeout(() => this.type2(), this.wait2);" is called again, "iiiiii" is being deleted from "txt2".
document.addEventListener('DOMContentLoaded', init2);
const TypeWriter2 = function (txtElement2, words2, wait2 = 3000) {
this.txtElement2 = txtElement2;
this.words2 = words2;
this.wait2 = parseInt(wait2, 10);
this.txt2 = '';
this.type2();
}
TypeWriter2.prototype.type2 = function () {
this.txt2 = this.words2.substring(0, this.txt2.length + 1)
if (this.txt2.substr(this.txt2.length - 3) === "...") {
this.txt2 = this.txt2 + "iiiii"
this.txtElement2.innerHTML = `<span class="intro-text">${this.txt2}</span>`;
} else {
this.txtElement2.innerHTML = `<span class="intro-text">${this.txt2}</span>`;
}
setTimeout(() => this.type2(), this.wait2);
}
function init2() {
const txtElement2 = document.querySelector('.intro-text');
const words2 = txtElement2.getAttribute('hello-txt');
const wait2 = txtElement2.getAttribute("data2-wait");
new TypeWriter2(txtElement2, words2, wait2);
}
Thanks in advance!
I was unable to reproduce the bug as described using the posted code, but in all likelihood you can resolve the problem by changing your else statement to an else if such that the type2 method stops being called as soon as all of the text in the "hello-txt" attribute has been added to txtElement2.innerHTML
Attempted repro case: https://jsbin.com/wovatit/1/edit?html,js,output
document.addEventListener('DOMContentLoaded', init2);
const TypeWriter2 = function (txtElement2, words2, wait2 = 3000) {
this.txtElement2 = txtElement2;
this.words2 = words2;
this.wait2 = parseInt(wait2, 10);
this.txt2 = '';
this.type2();
}
TypeWriter2.prototype.type2 = function () {
console.log('called');
this.txt2 = this.words2.substring(0, this.txt2.length + 1)
if (this.txt2.substr(this.txt2.length - 3) === "...") {
this.txt2 = this.txt2 + "iiiii"
this.txtElement2.innerHTML = `<span class="intro-text">${this.txt2}</span>`;
console.log("finished")
} else if(this.txt2.length <= this.words2.length){
this.txtElement2.innerHTML = `<span class="intro-text">${this.txt2}</span>`;
setTimeout(() => this.type2(), this.wait2);
} else{
console.log("finsished")
}
}
function init2() {
const txtElement2 = document.querySelector('.intro-text');
const words2 = txtElement2.getAttribute('hello-txt');
const wait2 = txtElement2.getAttribute("data2-wait");
new TypeWriter2(txtElement2, words2, wait2);
}

How to refactor this double forLoop with lodash?

I have selectedTags which holds up to 3 tags.
vm.tags Could contain thousands, most likely just hundreds of tags that I need to compare too.
If the ids of the 3 tags match the id of a tag inside of vm.tags I need to turn their borders on. There are 3 borders too: border1, border2, border3.
const tagsColorCheck = () => {
let name, selected, its_ticker;
let selectedTags = TagsFactory.retrieveTickerTags('onlyTags');
if (selectedTags.length > 0) {
for (let i=0; i<vm.tags.length; i++) {
for (let j=0; j<selectedTags.length; j++) {
if (selectedTags[j].term_id == vm.tags[i].term_id) {
name = 'border'+ ( j + 1 );
selected = 'selected';
its_ticker = 'its_ticker';
vm.tags[i][name] = true;
vm.tags[i][selected] = true;
vm.tags[i][its_ticker] = selectedTags[j].its_ticker;
}
}
}
}
};
So far here is what I have in process (_.each):
const tagsColorCheck = () => {
let name, selected, its_ticker, vmTerm, term_1, term_2, term_3, ticker_1, ticker_2, ticker_3;
let selectedTags = TagsFactory.retrieveTickerTags('onlyTags');
if (!_.isEmpty(selectedTags)) {
vmTerm = R.findIndex(R.propEq('term_id', selectedTags[0].term_id))(vm.tags);
}
if (selectedTags[0]) { term_1 = parseInt(selectedTags[0].term_id); ticker_1 = selectedTags[0].its_ticker; }
if (selectedTags[1]) { term_2 = parseInt(selectedTags[1].term_id); ticker_2 = selectedTags[1].its_ticker; }
if (selectedTags[2]) { term_3 = parseInt(selectedTags[2].term_id); ticker_3 = selectedTags[2].its_ticker; }
_.each(vm.tags, (tag) => {
if (tag.term_id === term_1) {
tag.selected = true;
tag.border1 = true;
tag.its_ticker = ticker_1;
}
if (tag.term_id === term_2) {
tag.selected = true;
tag.border2 = true;
tag.its_ticker = ticker_2;
}
if (tag.term_id === term_3) {
tag.selected = true;
tag.border3 = true;
tag.its_ticker = ticker_3;
}
})
};
And this (for of loop):
const tagsColorCheck = () => {
let name, selected, its_ticker, vmTerm, term_1, term_2, term_3, ticker_1, ticker_2, ticker_3;
let selectedTags = TagsFactory.retrieveTickerTags('onlyTags');
const borderRizeTag = (tag) => {
if (tag.term_id === term_1) {
tag.selected = true;
tag.border1 = true;
tag.its_ticker = ticker_1;
}
if (tag.term_id === term_2) {
tag.selected = true;
tag.border2 = true;
tag.its_ticker = ticker_2;
}
if (tag.term_id === term_3) {
tag.selected = true;
tag.border3 = true;
tag.its_ticker = ticker_3;
}
return tag;
}
if (!_.isEmpty(selectedTags)) {
vmTerm = R.findIndex(R.propEq('term_id', selectedTags[0].term_id))(vm.tags);
}
if (selectedTags[0]) { term_1 = parseInt(selectedTags[0].term_id); ticker_1 = selectedTags[0].its_ticker; }
if (selectedTags[1]) { term_2 = parseInt(selectedTags[1].term_id); ticker_2 = selectedTags[1].its_ticker; }
if (selectedTags[2]) { term_3 = parseInt(selectedTags[2].term_id); ticker_3 = selectedTags[2].its_ticker; }
for (let tag of vm.tags) {
console.log(tag);
tag = borderRizeTag(tag);
}
console.log('vmTerm',vmTerm);
};
ES6 fiddle to run: http://www.es6fiddle.net/is0prsq9/ (note, copy the entire text, and paste it inside a browser console or a node REPL, and then examine the value of tags to see the result)
It's not lodash, and you don't really need it with ES6 constructs. Relevant code:
const tagsColorCheck = () => {
let tags = TagsFactory.retrieveTickerTags('onlyTags')
sel.forEach( (s,i) =>
tags.filter(t => t.term_id === s.term_id).forEach( t => {
t['border' + (i+1)] = true
t.selected = true
t.its_ticker = s.its_ticker
})
)
return tags
}
If you were writing this in a functional language, you would have access to a list comprehension and it would be a bit cleaner. Essentially, this is a pretty clear case of (for every x in a and y in b) so a list comprehension is what you need, but you don't have it in javascript (mozilla has it, but not useful outside of that realm).
The result is a somewhat functional approach -- however, it can never really be functional in pure javascript. Possibly the most important benefit of the functional paradigm are immutable data structures where you would compose your new list. Instead, you just modify them in place here, which really is not very functional at all. Still, if you prefer the each approach to a literal incremental one, as you have done above and as I did in my post, then it's a (albeit slower but arguably cleaner) better approach.
Figured out an awesome solution! :D using both _lodash and ramda.
So below, immediately each is quicker to reason about, then using R.equals to compare if the term_ids match. Then setting the values of the keys on the correct tag object.
if (!_.isEmpty(selectedTags)) {
_.each(vm.tags, tag => {
_.each(selectedTags, (selectedTag, index) => {
let areTermIDsSame = R.equals;
if (areTermIDsSame(parseInt(selectedTag.term_id), parseInt(tag.term_id))) {
name = 'border'+ ( index + 1 );
selected = 'selected';
its_ticker = 'its_ticker';
tag[name] = true;
tag[selected] = true;
tag[its_ticker] = selectedTag.its_ticker;
}
});
})
}
The idea is simple - create an index of all tags by term_id. Iterate the selected tags. If a tag is found by id in the tags index, mutate it by assigning an object with the new properties.
btw - The only thing lodash is needed for is _.keyBy(), and you can easily do that using Array.prototype.reduce if you don't want to use lodash.
/** mocked vm **/
const vm = {
tags: [{ term_id: 1 }, { term_id: 2 }, { term_id: 3 }, { term_id: 4 }, { term_id: 5 }, { term_id: 6 }]
}
/** mocked TagsFactory **/
const TagsFactory = {
retrieveTickerTags: () => [{ term_id: 1, its_ticker: 'ticker 1' }, { term_id: 4, its_ticker: 'ticker 4' }, { term_id: 5, its_ticker: 'ticker 5' }]
};
const tagsColorCheck = () => {
const selectedTags = TagsFactory.retrieveTickerTags('onlyTags');
if (selectedTags.length === 0) { // if selectedTags is empty exit
return;
}
const vmTagsIndex = _.keyBy(vm.tags, (tag) => tag.term_id); // create an index of tags by term_id
selectedTags.forEach(({
term_id, its_ticker
}, index) => { // loop through selectd tags and retreive term_id and its_ticker from the current selected tag
const tag = vmTagsIndex[term_id]; // find the tag in the vmTagsIndex
if (!tag) { // if the id doesn't exist in vmTagsIndex exit
return;
}
Object.assign(tag, { // mutate the tag by assigining it an object with the available properties
selected: true,
[`border${index + 1}`]: true,
its_ticker
});
});
};
tagsColorCheck();
console.log(vm.tags);
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.15.0/lodash.min.js"></script>

Categories

Resources