Strange error with asynchronous code in Mutation Observer - javascript

I'm working on an third party addon for a WordPress page builder for which the code is based on Vue. Concretely, I'm working on an Emmet styled command line to add new elements to the UI with commands like: > section > container > button.
So far, so good. Since this Page Builder does not currently provide an API to interact inside the Vue context / state, I need to be creative. Currently it is a mixture between event dispatching and mutation observers. I manually go down each click path and thus create the elements as I need them.
This works, but only to a limited extent. I can't get any further at one point. The code you find below only goes up to the second layer.
section > container will work. The code will produce a section element with a nested container element inside. But: section > container > button will not work. The result here also is the same as before. The third element (button) is missing.
Here is the code:
async addElements(command) {
try {
// Split elements by one or more spaces
let elements = command.split(">");
// Remove items from array which are empty strings
elements = elements.filter((item) => item);
// Remove whitespace from all elements in the array
elements = elements.map((item) => item.replace(/\s+/, ""));
for (let i = 0; i < elements.length; i++) {
// Remove whitespace
elements[i] = elements[i].replace(" ", "");
// Add Element
await this.addElement({
element: elements[i],
index: i,
elementsArray: elements,
});
}
} catch (e) {
console.log(e);
}
}
async addElement({ element, elementsArray, index }) {
// if is empty string, skip
if (element == "") {
return;
}
let newCreatedElement;
let newCreatedElements = [];
let mutationCountMax = elementsArray.length;
// Add Mutation Observer to wait for the element to be created
let observer = new MutationObserver(async (mutations) => {
if (this.mutationCount >= mutationCountMax) {
observer.disconnect();
return;
}
this.mutationCount++;
// Create a list from all li elements from the mutation.addedNodes
let liList = [];
mutations.forEach((mutation) => {
if (mutation.addedNodes && mutation.addedNodes.length) {
liList.push(
...mutation.addedNodes[0].querySelectorAll("li")
);
}
});
// Remove mutations which have no added nodes
mutations = mutations.filter(
(mutation) => mutation.addedNodes.length > 0
);
// Remove mutations which have #builder-panel-header as parent
mutations = mutations.filter(
(mutation) =>
mutation.addedNodes[0].parentElement.id !=
"builder-panel-header"
);
// Remove mutations which target is not tag UL or tag LI
mutations = mutations.filter(
(mutation) =>
mutation.addedNodes[0].tagName == "UL" ||
mutation.addedNodes[0].tagName == "LI"
);
let addedNode = mutations[0].addedNodes[0];
newCreatedElement = [addedNode];
// if newCreatedElement is UL, get the first LI
newCreatedElement = newCreatedElement[0].querySelectorAll("li");
newCreatedElement.forEach((element) => {
// Add click event to the element
element
.querySelector(".builder-draggable-handle")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
/**
* Wait for the nested LI Elements to be added to the page
*/
let liObserver = new MutationObserver(async (liMutations) => {
// Wait until the nested li elements have been added to the page
if (
liMutations.some(
(liMutation) => liMutation.addedNodes.length > 0
)
) {
// Get all of the nested li elements
let liElements = addedNode.querySelectorAll("li li");
console.log(liElements);
console.log("Detected Nested LI Elements");
liElements.forEach((element) => {
// Add click event to the element
element
.querySelector(".builder-draggable-handle")
.dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
});
// Disconnect the observer once the li elements have been found
liObserver.disconnect();
}
});
// Observe the addedNode element for changes
liObserver.observe(addedNode, {
childList: true,
subtree: true,
});
/**
* Create Element
*/
if (this.mutationCount != 1) {
await this.createElement(element);
}
observer.disconnect();
});
// Observe the body
observer.observe(document.querySelector("#builder-structure"), {
childList: true,
// Not observing attributes
attributes: false,
// Also deep elements
subtree: true,
});
if (index == 0) {
this.createElement(element);
}
}
async createElement(element) {
console.log("Create Element: " + element);
// Code to add the element to the UI structure
// ...
}
The console.log output for the command section > container > button is the following:
Create Element: section
Create Element: container
Create Element: button
NodeList [li#element-aqtufh
Detected Nested LI Elements
NodeList [li#element-aqtufh
Detected Nested LI Elements
NodeList [li#element-aqtufh
Detected Nested LI Elements
I think the problem lies in the asynchrony. The output in the console also seems to be not quite correct.
In that case I need the second mutation observer, because in the first one strangely the nested LI elements can't be found - I don't understand why. So it works, but only with the above mentioned problem.
Where is the error in my code?

Related

ReactJS - Print specific elements in the DOM

I am using ReactJS on an App and currently need to be able to print some elements from the page on user's request (click on a button).
I chose to use the CSS media-query type print (#media print) to be able to check if an element should be printed, based on a selector that could be from a class or attribute on an Element. The strategy would be to hide everything but those "printable" elements with a stylesheet looking like:
#media print {
*:not([data-print]) {
display: none;
}
}
However, for this to work I need to also add the chosen print selector (here the attribute data-print) on every parent element each printable element has.
To do that here's what I've tried so far:
export default function PrintButton() {
useEffect(() => {
const handleBeforePrint = () => {
printNodeSelectors.forEach((selector) => {
const printableElement = document.querySelector(selector);
if (printableElement != null) {
let element = printableElement;
while (element.parentElement) {
element.setAttribute("data-print", "");
element = element.parentElement;
}
element.setAttribute("data-print", "");
}
});
};
const handleAfterPrint = () => {
printNodeSelectors.forEach((selector) => {
const printableElement = document.querySelector(selector);
if (printableElement != null) {
let element = printableElement;
while (element.parentElement) {
element.removeAttribute("data-print");
element = element.parentElement;
}
element.removeAttribute("data-print");
}
});
};
window.addEventListener("beforeprint", handleBeforePrint);
window.addEventListener("afterprint", handleAfterPrint);
return () => {
window.removeEventListener("beforeprint", handleBeforePrint);
window.removeEventListener("afterprint", handleAfterPrint);
};
}, []);
return <button onClick={() => window.print()}>Print</button>;
}
With printNodeSelectors being a const Array of string selectors.
Unfortunately it seems that React ditch out all my dirty DOM modification right after I do them 😭
I'd like to find a way to achieve this without having to manually put everywhere in the app who should be printable, while working on a React App, would someone knows how to do that? 🙏🏼
Just CSS should be enough to hide all Elements which do not have the data-print attribute AND which do not have such Element in their descendants.
Use the :has CSS pseudo-class (in combination with :not one) to express that 2nd condition (selector on descendants):
#media print {
*:not([data-print]):not(:has([data-print])) {
display: none;
}
}
Caution: ancestors of Elements with data-print attribute would not match, hence their text nodes (not wrapped by a tag) would not be hidden when printing:
<div>
<span>should not print</span>
<span data-print>but this should</span>
Caution: text node without tag may be printed...
</div>
Demo: https://jsfiddle.net/6x34ad50/1/ (you can launch the print preview browser feature to see the effect, or rely on the coloring)
Similar but just coloring to directly see the effect:
*:not([data-print]):not(:has([data-print])) {
color: red;
}
<div>
<span>should not print (be colored in red)</span>
<span data-print>but this should</span>
Caution: text node without tag may be printed...
</div>
After some thoughts, tries and errors it appears that even though I managed to put the attribute selector on the parents I completely missed the children of the elements I wanted to print! (React wasn't at all removing the attributes from a mysterious render cycle in the end)
Here's a now functioning Component:
export default function PrintButton() {
useEffect(() => {
const handleBeforePrint = () => {
printNodeSelectors.forEach((selector) => {
const printableElement = document.querySelector(selector);
if (printableElement != null) {
const elements: Element[] = [];
// we need to give all parents and children a data-print attribute for them to be displayed on print
const addParents = (element: Element) => {
if (element.parentElement) {
elements.push(element.parentElement);
addParents(element.parentElement);
}
};
addParents(printableElement);
const addChildrens = (element: Element) => {
elements.push(element);
Array.from(element.children).forEach(addChildrens);
};
addChildrens(printableElement);
elements.forEach((element) => element.setAttribute("data-print", ""));
}
});
};
const handleAfterPrint = () => {
document.querySelectorAll("[data-print]").forEach((element) => element.removeAttribute("data-print"));
};
window.addEventListener("beforeprint", handleBeforePrint);
window.addEventListener("afterprint", handleAfterPrint);
return () => {
window.removeEventListener("beforeprint", handleBeforePrint);
window.removeEventListener("afterprint", handleAfterPrint);
};
}, []);
return <button onClick={() => window.print()}>Print</button>;
}
I usually don't like messing with the DOM while using React but here it allows me to keep everything in the component without having to modify anything else around (though I'd agree that those printNodeSelectors need to be chosen from outside and aren't dynamic at the moment)

I cant add a "showDetails" (beginner) (addeventlisteners)

I have a very basic question which I just cant seem to work it out
I just want to basically create a function that will showDetails when I click in an object from a list which is called "pokemonList"
Nothing I do creates a function to what I need to do which is the object having a response after being clicked:
the part of the code in the question is:
.
`function eventListener(button, pokemon) {
button.addEventListener("click", function() {
showDetails(pokemon);
});
}
///////////this following part is incorrect, it needs to make the ''showDetails'' be responsive and ///////////show the pokemon(object) name. Please help me as I'm stuck and I've tried many things
**function showDetails(pokemon) {
eventListener("button");
console.log(pokemon);
}**
///////////////
pokemonList.forEach(function(pokemon) {
let pokemonList = document.querySelector(".pokemon-list");
let listItem = document.createElement("li");
let button = document.createElement("button");
button.innerText = pokemon.name;
button.classList.add("stylez");
listItem.appendChild(button);
pokemonList.appendChild(listItem);
});`
At the moment showDetails calls eventListener which calls showDetails which calls eventListener etc - which is bad.
So ideally you want to
Cache the list element first.
Using event delegation assign one listener to that element that can catch events from its child elements when they're fired and "bubble up" the DOM.
To minimise the adverse affects of repeatedly updating the DOM within your loop create a document fragment which you can append new elements, and which - post loop - you can append to the list element.
showDetails will handle the events from the list listener. In this example it checks to see if the child element that fired the event is a button, and then logs the text content of the button - but obviously you can change that to update a separate element etc.
// Cache the list element, and add a listener to it.
const list = document.querySelector('.pokemon-list');
list.addEventListener('click', showDetails);
const pokemonList = [
{ name: 'Bob' },
{ name: 'Ron' },
{ name: 'Sue' },
{ name: 'Jane' }
];
// Check that the child element that fired
// the event is a button, and log its text content
function showDetails(e) {
if (e.target.matches('button')) {
console.log(e.target.textContent);
}
}
// Create a document fragment
const frag = document.createDocumentFragment();
// Loop over the array and for each object
// add create a list item, and a button, append the button
// to the list item, and then the list item to the fragment
pokemonList.forEach(pokemon => {
const listItem = document.createElement('li');
const button = document.createElement('button');
button.textContent = pokemon.name;
button.classList.add('stylez');
listItem.appendChild(button);
frag.appendChild(listItem);
});
// Finally append the fragment of list items
// to the cached list element
list.appendChild(frag);
.stylez { background-color: lightgreen; }
<ul class="pokemon-list" />

js reverse remove, keep element to its DOM location

when we call element.remove(), element is removed from DOM since and element.isConnected will return false. is there a function that reverse remove method and put the element back into location?
let's say I have three elements: html_area, css_area and js_area. their display are managed individually by html_hider, js_hider, css_hider. when hider is clicked, I need to remove the area from DOM, also modifies container dimension. here's the thing, I want to keep these in order:
html
css
js
when they hide and show (appended or removed) form DOM, I want their sequence to be maintained. I manually coded it as:
html_area.connect = () => container.prepend(html_area);
css_area.connect = () => {
if(!html_area.isConnected){
container.prepend(css_area);
}else if(!js_area.isConnected){
container.append(css_area);
}else{
container.insertBefore(css_area, js_area);
}
}
js_area.connect = () => container.append(js_area);
html_hider.onclick = () => {
if(html_area.isConnected){
html_hider.classList.add('hide');
html_area.remove();
}else{
html_hider.classList.remove('hide');
html_area.connect();
}
aMode();
}
css_hider.onclick = () => {
if(css_area.isConnected){
css_hider.classList.add('hide');
css_area.remove();
}else{
css_hider.classList.remove('hide');
css_area.connect();
}
aMode();
}
js_hider.onclick = () => {
if(js_area.isConnected){
js_hider.classList.add('hide');
js_area.remove();
}else{
js_hider.classList.remove('hide');
js_area.connect();
}
aMode();
}
not robust and will be extremely complicated when elements and hiders pair more than 3. is there a way to reverse the remove method and put the element back in its original location?

How to add an event listener to dynamically generated content using Renderer2?

I am trying to add a click event listener to a div that is dynamically generated after page load but I can't seem to get the event to register. I am following the instructions found in this answer however, it is not working for me.
In my ngOnInit() I have a combineLatest():
combineLatest([this.params$, this.user$]).subscribe(([params, user]: [Params, User]) => {
this.artistId = parseInt(params['artist']);
this.user = user;
if (this.artistId) {
this.artistProfileGQL.watch({
id: this.artistId
}).valueChanges.subscribe((response: ApolloQueryResult<ArtistProfileQuery>) => {
this.artist = response.data.artist;
this.initElements(); // WHERE I CALL TO INITIALIZE DYNAMIC DOM ELEMENTS
});
})
In this block, I call initElements() which is where I create certain DOM elements. I've included most of them below. Essentially, I have a header element, and inside this header element, I create a followBtn, that looks like this (i removed the title, followers, elements etc from the code for brevity). I added comments in caps for the most relevant lines:
initElements() {
const parentElement = this.el.nativeElement;
this.header = parentElement.querySelector('ion-header');
// Create image overlay
this.imageOverlay = this.renderer.createElement('div');
this.renderer.addClass(this.imageOverlay, 'image-overlay');
this.colorOverlay = this.renderer.createElement('div');
this.renderer.addClass(this.colorOverlay, 'color-overlay');
this.colorOverlay.appendChild(this.imageOverlay);
this.header.appendChild(this.colorOverlay);
var artistHeader = this.renderer.createElement('div');
this.renderer.addClass(artistHeader, 'artist-header');
// HERES WHERE I CREATE MY BUTTON ELEMENT
this.followBtn = this.renderer.createElement('div');
this.renderer.addClass(this.followBtn, "follow-btn");
var followText = this.renderer.createText('FOLLOW');
this.renderer.appendChild(this.followBtn, followText);
this.renderer.appendChild(artistHeader, this.followBtn);
this.renderer.appendChild(this.imageOverlay, artistHeader);
// HERES WHERE I CREATE MY LISTENER
this.followButtonListener = this.renderer.listen(this.followBtn, 'click', (event) => {
console.log(event);
});
}
However, when I click on the element, I don't get anything printed to my console. If I change the target of the listener to a DOM element, the click listener works. What am I doing wrong?

How to disable nth level node if any (n-1)th node is selected in jsTree

Hi am using jsTree and created the below shown Tree
Now i want to disable n th node if (n-1) th node is selected,ie user cant able to select different level of nodes.
eg:
if user selected Koramangala,then infosys,Accenture,TCS,IBM,Wipro and their child nodes should be disable
If Bangalore is selected,Koramangala,electronicCity,WhiteField,Marathahally and their child should be disabled and disable same level childs in US& UK
Is it possible to achieve this requirement ????
Thanks in advance
You can use an attribute added by jsTree to all li elements - the aria-level attribute. It starts from 1 for root element and spans whole tree showing level for every node.
You will have to do this:
add some events to jsTree object - changed event to disable visible nodes from next level and below and open_node to update status of to-be disabled nodes previously hidden (non-existent in the DOM till this moment to be exact)
add conditionalselect plugin to disallow node selection if node is disabled
I kept the currently selected level in var currentlevel. You should check that it is kept local. Also you can surely optimize the code so it wouldn't repeat enable/disable functionality.
Check demo - JS Fiddle
Solution for the above mentioned requiremnt will be available here in jsFfiddle
Here am listing the features
At a time,only same level of nodes can be selectable
If select nth level node,then all lower level nodes will be disabled
If select nth level node and after that if select any higher level node is selected,then all lower level nodes selection will be removed
Here am adding the jquery code
var data = [{"id":"1","text":"India","state":{"opened":false},"parent":"#", "state": { "opened":true } },{"id":"1-1","text":"Banglore", "state": { "opened":true }, "parent":"1"},{"id":"1-1-1","text":"Koramangala","state":{"opened":false},"parent":"1-1"},{"id":"1-1-1-1","text":"Infosys ","state":{"opened":false},"parent":"1-1-1"},{"id":"1-1-1-1-1","text":"D","state":{"opened":false},"parent":"1-1-1-1"},{"id":"1-1-1-1-2","text":"E","state":{"opened":false},"parent":"1-1-1-1"},{"id":"1-1-1-1-3","text":"G","state":{"opened":false},"parent":"1-1-1-1"},{"id":"1-1-1-3","text":"Accenture ","state":{"opened":false},"parent":"1-1-1"},{"id":"1-1-1-3-8","text":"C","state":{"opened":false},"parent":"1-1-1-3"},{"id":"1-1-1-3:9","text":"E","state":{"opened":false},"parent":"1-1-1-3"},{"id":"1-1-2","text":"Electronic City","state":{"opened":false},"parent":"1-1"},{"id":"1-1-2-2","text":"TCS ","state":{"opened":false},"parent":"1-1-2"},{"id":"1-1-2-2-4","text":"C","state":{"opened":false},"parent":"1-1-2-2"},{"id":"1-1-2-2-5","text":"E","state":{"opened":false},"parent":"1-1-2-2"},{"id":"1-1-2-2-6","text":"F","state":{"opened":false},"parent":"1-1-2-2"},{"id":"1-1-2-2-7","text":"G","state":{"opened":false},"parent":"1-1-2-2"},{"id":"1-1-3","text":"WhiteField","state":{"opened":false},"parent":"1-1"},{"id":"1-1-3-4","text":"IBM ","state":{"opened":false},"parent":"1-1-3"},{"id":"1-1-3-4-10","text":"F","state":{"opened":false},"parent":"1-1-3-4"},{"id":"1-1-4","text":"Marathahally","state":{"opened":false},"parent":"1-1"},{"id":"1-1-4-5","text":"Wipro ","state":{"opened":false},"parent":"1-1-4"},{"id":"1-1-4-5-11","text":"G","state":{"opened":false},"parent":"1-1-4-5"},{"id":"1-2","text":"Chennai","state":{"opened":false},"parent":"1"},{"id":"1-2-5","text":"sholinganallur","state":{"opened":false},"parent":"1-2"},{"id":"1-2-6","text":"Tiruvanmiyur","state":{"opened":false},"parent":"1-2"},{"id":"2","text":"UK","state":{"opened":false},"parent":"#"},{"id":"2-3","text":"London","state":{"opened":false},"parent":"2"},{"id":"3","text":"US","state":{"opened":false},"parent":"#"},{"id":"3-4","text":"Texas","state":{"opened":false},"parent":"3"},{"id":"3-5","text":"Washington","state":{"opened":false},"parent":"3"},{"id":"3-6","text":"California","state":{"opened":false},"parent":"3"}]
$.jstree.defaults.core = {};
var currentlevel;
$('#tree')
.on('changed.jstree', function (event, data) {
if( data.action == 'select_node'){
$('#tree').find('li').removeClass('disabled_node');
console.log('select '+ data.node.text);
currentlevel = parseInt( $('#'+data.node.id).attr('aria-level') );
$('#tree').find('li').each( function() {
if($(this).attr('aria-level') > currentlevel) {
$(this).addClass('disabled_node');
// remove checks from levels below
$('#tree').jstree('deselect_node', '#'+this.id);
} else if($(this).attr('aria-level') < currentlevel) {
// remove checks from levels above
$('#tree').jstree('deselect_node', '#'+this.id);
}
});
}
if( data.action == 'deselect_node' && data.event && data.event.type === 'click'){
// if have other checked nodes at same level - do not enable children
if ( $('#tree').find('li:not(#'+data.node.id+')[aria-level="'+currentlevel+'"][aria-selected="true"]').length>0 ) {
return;
}
$('#tree').find('li').each( function() {
if($(this).attr('aria-level') > currentlevel) {
$(this).removeClass('disabled_node');
}
});
}
})
.on('open_node.jstree', function(event, obj ) {
$('#'+obj.node.id).find('li').each( function() {
if($(this).attr('aria-level') > currentlevel) {
$(this).addClass('disabled_node');
}
});
})
.jstree({
"core" : {
"data" : data,
"multiple": true,
"themes": {
"url": true,
"icons": true
}
},
"checkbox" : {
"three_state" : false
},
"conditionalselect" : function (node, event) {
return !$('#'+node.id).hasClass('disabled_node');
},
"plugins" : [ "checkbox","conditionalselect" ]
});
Thanks to nikolay-ermakov
All the other answers\fiddles listed above works in most scenarios but one. All the above scenarios works fine as long as all the nodes are expanded (becuase once you collapse a node, it is removed from the dom).
Assume you select a node at level three and then you collapse the node at level three and then you select a node at level 1, it system does not un-select level 3 node (as it was removed from dom when node was collapsed) and nodes at level 1 and 3 remains selected.
To fix the issue, I am sending the node an additional field in the JSON call level which tells you about the current level of the node.
var myTree = $('#tree');
myTree .on('changed.jstree', function(event, data) {
mee.disableTreeNodesAtOtherLevels(event,data,myTree );
});
disableTreeNodesAtOtherLevels(event,data, tree){
var currentlevel = parseInt($('#' + data.node.id).attr('aria-level'));
var selectedNodes = tree.jstree(true).get_selected(true);
for (var i = 0; i < selectedNodes.length; i++) {
if(selectedNodes[i].original.level !== currentlevel){
tree.jstree(true).deselect_node(selectedNodes[i], true);
}
}
}
All this works based on assumption the data you are binding to tree is having a property called level

Categories