JavaScript: weird bug when using Promises to load images in order - javascript

I have an array of image urls and I want to display these images on the webpage in the order of the placement of their corresponding URL in the array.
for example, we have
const imgUrls = [
"https://picsum.photos/400/400",
"https://picsum.photos/200/200",
"https://picsum.photos/300/300"
];
in which case we want to display 400/400 first, then 200/200 and lastly 300/300.
If we implement it naively then the order is not guaranteed.
function loadImages(imgUrls, root) {
imgUrls.forEach((url) => {
const image = document.createElement("img");
image.onload = () => {
root.append(image);
};
image.src = url;
});
}
So I use Promises to manage the async flow control
async function loadImagesInOrder(imgUrls, rootEl) {
const promises = imgUrls.map(
(url) =>
new Promise((resolve, reject) => {
const image = document.createElement("img");
image.onload = resolve;
image.onerror = reject;
image.src = url;
})
);
for (const p of promises) {
const { currentTarget } = await p;
rootEl.append(currentTarget);
}
}
It works, but not all of the time. With this implementation, Sometimes some images are going to be null so you end up with null on the webpage.
This is the live demo
https://codesandbox.io/s/load-images-in-order-t16hs?file=/src/index.js
Can someone point out what the problem is? Also is there any better way to make sure the image appear on the page in the order of the URL in the array?

It looks like the .currentTarget of the event is sometimes null for some reason. An easy fix is to resolve the Promise with the image itself, instead of going through the problematic load handler. Keith found why in the comments:
Note: The value of event.currentTarget is only available while the event is being handled
But when you do
for (const p of promises) {
const { currentTarget } = await p;
rootEl.append(currentTarget);
}
All of the images have their loading process initiated immediately, but they load asynchronously, and they often don't load in order. The .currentTarget will not exist if the Promise resolves after image has already loaded - for example, if image 1, then image 3 loads, then image 2 loads, image 3 will have already been loaded by the time the third image's
const { currentTarget } = await p;
runs.
If you simply need to order the images properly in the end, an easier approach would be to append them immediately.
const root = document.querySelector("#app");
const imgUrls = [
"https://picsum.photos/400/400",
"https://picsum.photos/200/200",
"https://picsum.photos/300/300"
];
for (const src of imgUrls) {
root.appendChild(document.createElement('img')).src = src;
}
<div id="app">

Related

SVG Image load event firing before image is actually loaded

I have an SVG image that contains <image> tags with hrefs to other external images. For an function on this SVG I need to convert these URLs to their dataURL equivalent before exporting. I have the conversion working am having some difficulties waiting for all the URLs to finish converting prior to calling the function.
My code looks something like the following:
// Convert a URL to a dataURL, returns Promise for when it's complete
const convertUrlToDataUrl = (url) => {
let resolvePromise, rejectPromise;
const promise = new Promise((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
const img = document.createElement('img');
img.crossOrigin = 'anonymous';
img.onload = (event) => {
const loadedImage = event.target;
const {width, height} = loadedImage;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
context.drawImage(loadedImage, 0, 0);
const dataUrl = canvas.toDataURL();
resolvePromise(dataUrl);
};
img.onerror = () => {
rejectPromise();
};
img.src = url;
return promise;
};
// Updates a SVG image tags href to a dataURL of the image
const updateImageToDataUrl = async (image) => {
const url = image.getAttribute('href') || image.getAttribute('xlink:href');
try {
dataUrl = await convertUrlToDataUrl(url);
} catch(e) {
dataUrl = 'some backup data URL';
}
// Update image tag image reference to data URL and create imageUrlUpdated promise to complete when image has loaded
let resolvePromise, rejectPromise;
const imageUrlUpdated = new Promise((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
image.addEventListener('load', e => resolvePromise(), {once: true});
image.addEventListener('error', () => rejectPromise(), {once: true});
// Update image (both xlink:href and href for backwards compatiablity)
if (image.hasAttribute('xlink:href')) { image.setAttribute('xlink:href', dataUrl); }
if (image.hasAttribute('href')) { image.setAttribute('href', dataUrl); }
// Wait for image to load after updating URL
try {
await imageUrlUpdated;
console.log('Image fully updated');
} catch (e) {
console.log('Something went wrong');
}
};
// Get all svg image elements to change
const svgImages = getSVGImageElements();
// Start URL conversion process for each image and store promise of each
const imageUpdatingPromises = [];
svgImages.forEach(image => {
imageUpdatingPromises.push(updateImageToDataUrl(image));
});
// Wait for all images to be loaded
await Promise.allSettled(imageUpdatingPromises);
console.log('Starting doStuffWithSVG');
doStuffWithSVG();
The intended behaviour of the above code is to wait until all the SVG image tags have been updated with data URLS before calling doStuffWithSVG();. Upon testing it appears that doStuffWithSVG() is running before all the images have updated (I say this as I am getting an error that occurs when using an external URL instead of a data URL for my particular use case).
While trying to debug this all the logs "Image fully updated" happen before the log "Starting doStuffWithSVG" and "Something went wrong" is never called which indcates that my promises all do seem to be running in the right order and waiting appropriately. I can confirm that the actual href and xlink:href attributes on the image tag are all data URLs by the time doStuffWithSVG is called but I am still getting an error. In addition when calling this code in succession it will work after the first time, my guess for why is that the images are cached so the actual images update faster the subsequent runs.
I should mention that I am only seeing this issue in Safari (I am still seeing all the same expected logging in Safari though) but I am not convinced it is a browser specific issue but that the safari javascript engine is just slower and is why I am seeing it here (Can confirm that compared to chrome or firefox, Safari takes much longer to convert the images to data URLs). The reason I think this is that I have not been able to find any other differences in what is getting called or in what order between the browsers besides speed (Safari still uses xlink:href instead of href but that doesn't seem to be making a difference here).
With everything above it seems to me like the load event is firing on the SVGImageElements before the actual image has loaded. That or I have made a mistake in setting up and awaiting my Promises somewhere. Any help on this would be great as I have run out of ideas of how else I can accomplish this or what else the problem could be.

How to get total audio duration from multiple audio files

I am trying to get the total duration of audio from an array of audio paths.
Here is what the array would look like:
var sound_paths = ["1.mp3","2.mp3",...]
I have looked at this post, which helped:
how to get audio.duration value by a function
However, I do not know how to implement this over an array. The idea is that I want to loop over each audio file, get its duration, and add it to a "sum_duration" variable.
I cannot seem to figure out a way to do this over a for loop. I have tried Promises (which I am admittedly new at): (Note the functions come from a class)
getDuration(src,cb){
// takes a source audio file and a callback function
var audio = new Audio();
audio.addEventListener("loadedmetadata",()=>{
cb(audio.duration);
});
audio.src = src;
}
getAudioArrayDuration(aud_path_arr){
// takes in an array of audio paths, finds the total audio duration
// in seconds
return new Promise((resolve)=>{
var duration = 0;
for(const aud_path in aud_path_arr){
var audio = new Audio();
audio.src = aud_path;
audio.onloadedmetadata = ()=>{
console.log(audio.duration);
duration += audio.duration;
}
resolve(duration);
}
});
}
However, this obviously does not work, and will just return the duration value of 0.
How can I loop over audio files and return the total audio duration of each file summed?
I think, in this case, working with promises is the right approach, time to get used to them ;) Try to remember, a promise will fullfil your wish in the near future. What makes your problem harder is that you have an array of files to check, each will need to separately be covered by it's own Promise, just so that your program can know when all of them have been fullfilled.
I always call my 'promised' getters 'fetch...', that way I know it'll return a promise instead of a direct value.
function fetchDuration(path) {
return new Promise((resolve) => {
const audio = new Audio();
audio.src = path;
audio.addEventListener(
'loadedmetadata',
() => {
// To keep a promise maintainable, only do 1
// asynchronous activity for each promise you make
resolve(audio.duration)
},
);
})
}
function fetchTotalDuration(paths) {
// Create an array of promises and wait until all have completed
return Promise.all(paths.map((path) => fetchDuration(path)))
// Reduce the results back to a single value
.then((durations) => durations.reduce(
(acc, duration) => acc + duration,
0,
))
;
}
At some point, your code is going to have to deal with this asynchronous stuff, I honestly believe that Promises are the easiest way to do this. It takes a little getting used to, but it'll be worth it in the end. The above could be used in your code something along the lines of:
window.addEventListener('DOMContentLoaded', () => {
fetchTotalDuration(["1.mp3","2.mp3",...])
.then((totalDuration) => {
document.querySelector('.player__total-duration').innerHTML = totalDuration;
})
;
});
I hacked this together real quick, so you'll have to adapt it to your function structure, but it's a working code snippet that should send you in the right direction.
Simply keep track of which audio files have been loaded, and if that matches the number of audio files queried, you call the callback with the total duration.
You should also take failing requests into account, so that if the loadedmetadata event never fires, you can react accordingly (by either falling back to 0 duration for that file, or throwing an Exception, etc.).
const cb = function(duration) {
console.log(`Total duration: ${duration}`);
};
let sound_paths = ["https://rawcdn.githack.com/anars/blank-audio/92f06aaa1f1f4cae365af4a256b04cf9014de564/5-seconds-of-silence.mp3","https://rawcdn.githack.com/anars/blank-audio/92f06aaa1f1f4cae365af4a256b04cf9014de564/2-seconds-of-silence.mp3"];
let totalDuration = 0;
let loadedSounds = [];
sound_paths.map(src => {
const audio = new Audio();
audio.addEventListener("loadedmetadata", ()=>{
totalDuration += audio.duration;
loadedSounds.push(audio);
if ( loadedSounds.length === sound_paths.length ) {
cb( totalDuration );
}
});
audio.src = src;
});

Chrome extension with SVG icons (chrome.browserAction.setIcon)

I am using scalable SVG icons in my Chrome extension.
chrome.browserAction.setIcon({
tabId: tabId,
path: '../icons/' + icon + '/scalable.svg'
});
I want to switch icons based on some parameters, so I have visual feedback.
What I have realized is that when I am switching icons very quickly, Chrome is messing up and often I end up seeing the wrong icon. I added console.log prints to code to ensure I am switching icons properly and I see that my code has no errors.
It looks like Chrome executes such change of icon requests asynchronously and conversion of SVG to pixels takes sometimes longer than usual. That leads to an execution in the wrong order.
So for example if I switch icons from A to B, then to C; then to D, ... at the end I may see C, although the last request for change was to switch it to D.
Any ideas on how to fix this annoying problem?
Chain the calls to the API using Promise
If you call setIcon often, create a cache of imageData yourself and use it instead of path because the API re-reads the source icon each time and re-creates imageData.
Here's a generic example, not tested:
const queue = {};
const cache = {};
// auto-clean the queue so it doesn't grow infinitely
chrome.tabs.onRemoved.addListener(tabId => delete queue[tabId]);
async function setIcon(tabId, icon) {
const url = '../icons/' + icon + '/scalable.svg';
const imageData = await (cache[url] || (cache[url] = loadImageData(url)));
queue[tabId] = (queue[tabId] || Promise.resolve()).then(() =>
new Promise(resolve =>
chrome.browserAction.setIcon({tabId, imageData}, resolve)));
}
function loadImageData(url) {
return new Promise((resolve, reject) => {
const data = {};
const img = new Image();
img.src = url;
img.onload = () => {
for (const size of [16, 32]) {
const canvas = document.createElement('canvas');
document.documentElement.appendChild(canvas);
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
data[size] = ctx.getImageData(0, 0, size, size);
canvas.remove();
}
resolve(data);
};
img.onerror = reject;
});
}

How to convert multiple images to base64 with canvas

I am storing base64 encoded images, and at the moment I can only create one code (i'm attempting to create two, but it appears the second is being overwritten). I don't get the over-arching concept of canvas drawing, so I believe that is the root of my issue when trying to solve this problem.
current behavior: It stores the same DataUrl in local storage twice. It does log the correct info. the favicon-green is getting stored, just not red
How do I encode multiple base64 images with canvas?
html:
<head>
...
<link id="favicon" rel="icon" src="/assets/favicon-red-16x16.ico.png">
</head>
<body>
...
<!-- hidden images to store -->
<img id="favicon-green" rel="icon" src="/assets/favicon-green-16x16.ico.png" width="16" height="16" />
<img id="favicon-red" rel="icon" src="/assets/favicon-red-16x16.ico.png" width="16" height="16" />
...
</body>
js:
// cache images
function storeImages() {
// my sorry attempt to create two canvas elements for two image encodings
var canvasGreen = document.createElement('canvas');
var canvasRed = document.createElement('canvas');
// painting both images
var ctxGreen = canvasGreen.getContext('2d');
var ctxRed = canvasRed.getContext('2d');
// getting both images from DOM
var favGreen = document.getElementById('favicon-green');
var favRed = document.getElementById('favicon-red');
// checking if images are already stored
var base64Green = localStorage.getItem('greenFavicon');
var base64Red = localStorage.getItem('redFavicon');
console.log('storing...')
if (base64Green == null && window.navigator.onLine || base64Red == null && window.navigator.onLine) {
ctxGreen.drawImage(favGreen, 0, 0);
ctxRed.drawImage(favRed, 0, 0);
// getting images (the DataUrl is currently the same for both)
base64Green = canvasGreen.toDataURL();
base64Red = canvasRed.toDataURL();
localStorage.setItem('greenFavicon', base64Green);
localStorage.setItem('redFavicon', base64Red);
console.log("are they equal : ", base64Green == base64Red); // returns true
}
}
storeImages();
I don't see anything particularly wrong with your code. If the code isn't a direct copy and paste, I would look through it with a fine-tooth come to make sure you don't switch any red and green around.
There shouldn't be any surprising mechanisms when it comes to converting canvases to data URLs.
Here is a quick example of two:
const a = document.createElement('canvas');
const b = document.createElement('canvas');
const aCtx = a.getContext('2d');
const bCtx = b.getContext('2d');
aCtx.fillStyle = '#000';
aCtx.fillRect(0, 0, a.width, a.height);
const aUrl = a.toDataURL();
const bUrl = b.toDataURL();
console.log(aUrl == bUrl, aUrl, bUrl);
console.log('First difference index:', Array.prototype.findIndex.call(aUrl, (aChar, index) => aChar !== bUrl[index]));
Notice that they are different. However, notice that they also start out very similar, and you have to go quite a ways over to start seeing differences (in my example, character 70). I would double-check that they are actually the same (by comparing them like I did). It could be it just looks the same.
Another thing you might do, which is more of a code style thing, but could also help with accidentally green and red mixups, is make a function to save just one, then call it twice.
const saveImage = (imageId, key) => {
if (localStorage.getItem(key)) {
return; // already saved
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const image = document.getElementById(imageId);
ctx.drawImage(image, 0, 0);
if (window.navigator.onLine) {
localStorage.setItem(key, canvas.toDataURL());
}
}
saveImage('favicon-green', 'greenFavicon');
saveImage('favicon-red', 'redFavicon');
Not only does that clean up your code and keep it DRY, but it also helps avoid accidental mix-ups between red and green in your function.
After some comments back and forth, I realized another possibility is you are trying to draw the images to the canvas before the images are loaded. This will cause it to draw blank images, but otherwise act like it is working fine.
You can quickly test this by console logging this:
console.log(image.width === 0);
after setting the image variable. If the value is true, then the image isn't loaded yet (before loading, images will have a width and height of 0). You need to make sure to wait until the image is loaded before trying to save it.
The best way to do this is with an addEventListener():
document.getElementById('favicon-green').addEventListener('load', () => {
saveImage('favicon-green', 'greenFavicon');
});
There is one more catch with this, in that if the image is somehow already loaded by the time that code runs, it'll never trigger. You need to look at the width of the image as well. Here is a function that does this all for you, and returns a Promise so you know it's done:
const saveImage = (imageId, key) => new Promise(resolve => {
if (localStorage.getItem(key)) {
return resolve(); // already saved
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const image = document.getElementById(imageId);
const onImageLoaded = () => {
ctx.drawImage(image, 0, 0);
if (window.navigator.onLine) {
localStorage.setItem(key, canvas.toDataURL());
}
resolve();
}
if (image.width > 0) {
onImageLoaded();
} else {
image.addEventListener('load', onImageLoaded);
}
});
saveImage('favicon-green', 'greenFavicon').then(() => console.log('saved'));

Loop through array of file urls and download each one with React

I'm trying to do what I 'thought' would be a simple task. I have an array of URLs that I'd like to loop through and download on to the client machine when the user clicks a button.
Right now I have a parent component that contains the button and an array of the urls (in the state) that I'd like to loop through and download. For some reason, the way I'm doing it now only downloads one of the files, not all of the contents of the array.
Any idea how to do this correctly within React?
handleDownload(event){
var downloadUrls = this.state.downloadUrls;
downloadUrls.forEach(function (value) {
console.log('yo '+value)
const response = {
file: value,
};
window.location.href = response.file;
})
}
I would use setTimeout to wait a little bit between downloading each files.
handleDownload(event){
var downloadUrls = this.state.downloadUrls.slice();
downloadUrls.forEach(function (value, idx) {
const response = {
file: value,
};
setTimeout(() => {
window.location.href = response.file;
}, idx * 100)
})
}
In Chrome, this will also prompt the permission asking for multiple files download.

Categories

Resources