0

I want a function which prompts the user to select an image. It should then wait for an image to be selected and then return that image. I want to achieve this without creating a new input element and attaching a new eventlistener every time (atleast not without removing the old ones first) because I expect this button to be used A LOT!

What I have right now is successfully prompting the user can getting the image. But I am not returning it so I can use it however I want after the prompt_for_image() function call. Please help me D:

I added a console log button for testing purposes so you can see the image is being selected propperly.

const images = [];
const file_input = document.createElement('input');
file_input.type = 'file';
file_input.accept = 'image/*';
file_input.addEventListener('change', () => {
  const reader = new FileReader();
  reader.onload = () => images.push(reader.result);
  reader.readAsDataURL(file_input.files[0]);
});

function prompt_for_image() {
  file_input.value = null;
  file_input.click();
}
 document.getElementById('btn_add_new_image').addEventListener('click', async () => {
  await prompt_for_image();
});

document.getElementById('btn_log_images').addEventListener('click', () => { console.log(images) });
<button type='button' id='btn_add_new_image'>ADD NEW IMAGE</button>

<button type='button' id='btn_log_images'>console log image array</button>
Oscar R
  • 133
  • 1
  • 11

1 Answers1

4

You'll need to properly promisify FileReader, then promisify the addEventListener. This is easiest done by using the once parameter:

function readFileAsDataURL(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}
function waitForNextEvent(element, type) {
  return new Promise(resolve => {
    element.addEventListener(type, resolve, {once: true});
  });
}

Now you can do

const images = [];
const fileInput = Object.assign(document.createElement('input'), {
  type: 'file',
  accept: 'image/*',
});

async function promptForImage() {
  const promise = waitForNextEvent(fileInput, 'change');
  fileInput.value = null;
  fileInput.click();
  await promise;
  return fileInput.files[0];
}
document.getElementById('btn_add_new_image').addEventListener('click', async () => {
  images.push(await readFileAsDataURL(await promptForImage()));
//^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
});
document.getElementById('btn_log_images').addEventListener('click', () => {
  console.log(images);
});


function readFileAsDataURL(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}
function waitForNextEvent(element, type) {
  return new Promise(resolve => {
    element.addEventListener(type, resolve, {once: true});
  });
}
<button type='button' id='btn_add_new_image'>ADD NEW IMAGE</button>

<button type='button' id='btn_log_images'>console log image array</button>
Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • I initially expected the dispatch of a `"change"` event when nullifying the value programmatically, but — after reviewing the spec — I realized that events aren't fired for those synthetic mutations. This is good for a case like this, but seems unfortunate in general: because it appears as though there's no way to subscribe to those kinds of changes without polling or proxies. – jsejcksn Jan 27 '23 at 23:05
  • @jsejcksn I wasn't certain there either, but if it had not worked, I'd simple have moved the `waitForNextEvent(…)` call after the `fileInput.value` assignment. – Bergi Jan 27 '23 at 23:50
  • 1
    One issue I have noticed with this (and I know I didn't mention this requirement in my question), but this approach does not handle the case when the user clicks on the "cancel" button when prompted an image - meaning the change event never fires. The problem is: I have several different image inputs on my page - so when you open one of them, click cancel, open another, and then select something, the image is being added to both of them instead of just the one I most recently clicked on. – Oscar R Feb 01 '23 at 13:28
  • I half solved this by creating a new input element for each different input I need and passing it as a parameter to the promptForImage() function. But if I open, cancel, open, cancel ect the same input, then the image gets added once for each time I opened and closed it which is a problem for my specific use case. – Oscar R Feb 01 '23 at 13:37
  • I fully solved it now by getting rid of my previous comments changes and instead creating the input element inside the promtForImage() frunction and then removing it again with the remove() method after I have gotten the image file. – Oscar R Feb 01 '23 at 13:58
  • @OscarR Ah, good call. There appear [to be](https://stackoverflow.com/a/63773257/1048572) [some](https://stackoverflow.com/q/71435515/1048572) [workarounds](https://stackoverflow.com/q/4628544/1048572) for that… You should generally avoid keeping a promise unresolved, or at least clear document functions that return them. – Bergi Feb 01 '23 at 14:43
  • @Bergi How can I avoid leaving the promise unresolved? Is it not enough that the input element that the change event is attached to gets deleted for it to be picked up by garbage collection? – Oscar R Feb 01 '23 at 15:05
  • Yes, it should get garbage-collected fine and not lead to a memory leak, but it's hazardous when developers don't expect this behaviour from a function they are calling (consider `try { await waitForNextEvent(…); } finally { console.log('This is expected to always execute'); } console.log('…but never does')`). So you can try to detect the cancel click using the `focus` trick from the linked questions to reject the promise if the file dialog is closed without choosing a file. – Bergi Feb 01 '23 at 15:09