Generating images with HTML canvas only occurs once before stopping

I successfully implemented an image generation button using Nextjs and the HTML canvas element. The functionality works almost flawlessly - when a user clicks the "Generate Image" button, it creates an image containing smaller images with labels underneath each one.

Below is the code:

const downloadImage = () => {
    if (isGeneratingImage) return
    setIsGeneratingImage(true)

    // Define sizes for canvas components
    const canvasWidth = 1000;
    const logoHeight = 70;
    const logoMargin = 16;
    const symbolsPerRow = 6;
    const symbolCardWidth = 140;
    const symbolCardHeight = 175;
    const symbolCardGap = 8;
    const symbolImageSize = 96;

    // Calculate canvas height based on number of symbols
    // Symbols are arranged like a flexbox row with wrap
    const canvasHeight = Math.ceil(imageList.length / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);
    const canvasMargin = Math.ceil((canvasWidth - (symbolsPerRow * (symbolCardWidth + symbolCardGap)) + symbolCardGap) / 2);

    // Create canvas element in the html document
    const canvas = document.createElement('canvas');
    canvas.width = canvasWidth;
    canvas.height = canvasHeight;

    // Get 2d drawing context
    const ctx = canvas.getContext('2d')!;

    // Draw background image (same as the one used for the PageSection)
    const background = new Image();
    background.src = backgroundImageSrc;

    const RobotoBold = new FontFace('Roboto-Bold', 'url(/fonts/Roboto-Bold.ttf)')
    
    RobotoBold.load()
        .then(() => (
            new Promise<void>(resolve => {
                document.fonts.add(RobotoBold);

                background.onload = () => {
                    // Calculate scaling factors to cover the canvas while maintaining aspect ratio
                    const scaleX = canvasWidth / background.width;
                    const scaleY = canvasHeight / background.height;
                    const scale = Math.max(scaleX, scaleY);

                    // Calculate the new width and height of the image
                    const newWidth = background.width * scale;
                    const newHeight = background.height * scale;

                    // Calculate the position to center the image on the canvas
                    const x = (canvasWidth - newWidth) / 2;
                    const y = (canvasHeight - newHeight) / 2;

                    // Draw the background image with the calculated parameters
                    ctx.filter = 'brightness(0.4) blur(10px)';
                    ctx.drawImage(background, x, y, newWidth, newHeight);

                    // Reset filter
                    ctx.filter = 'none';

                    resolve();
                };
            })
        ))
        .then(() => {
            // List of promises for loading images
            const imagePromises: Promise<void>[] = [];

            // Load the logo image
            const logo = new Image();
            logo.src = FullLogo.src;
            imagePromises.push(new Promise<void>(resolve => {
                logo.onload = () => {
                    // Calculate the scaled width to maintain aspect ratio
                    const scaledWidth = (logoHeight / logo.naturalHeight) * logo.naturalWidth;

                    // Draw logo horizontally centered with a margin at the top
                    ctx.drawImage(
                        logo,
                        canvasWidth / 2 - scaledWidth / 2,
                        logoMargin,
                        scaledWidth,
                        logoHeight
                    );
                    resolve();
                }
            }));

            // Calculate values for drawing symbols in the last row
            const symbolsInLastRow = imageList.length % symbolsPerRow;
            const lastRowOffset = (symbolsPerRow - symbolsInLastRow) * (symbolCardWidth + symbolCardGap) / 2

            // Draw symbols with rounded backgrounds
            for (let i = 0; i < imageList.length; i++) {
                const imageReference = imageList[i];

                // If the symbol is in the last row, we need to adjust the x position to center it
                const isLastRow = i >= imageList.length - symbolsInLastRow;
            
                const x = (i % symbolsPerRow) * (symbolCardWidth + symbolCardGap) + symbolCardGap + canvasMargin + (isLastRow ? lastRowOffset : 0);
                const y = Math.floor(i / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);

                // Draw transparent gray background for symbol with rounded borders
                ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
                roundRect(ctx, x, y, symbolCardWidth, symbolCardHeight, 16);

                // Draw symbol image
                const image = new Image();
                image.src = imageReference.url;
                imagePromises.push(new Promise<void>(resolve => {
                    image.onload = () => {
                        ctx.drawImage(image, x + (symbolCardWidth - symbolImageSize) / 2, y + (symbolCardHeight - symbolImageSize) / 4, symbolImageSize, symbolImageSize);
                        resolve();
                    }
                }));

                // Draw symbol name
                ctx.fillStyle = 'white';
                ctx.font = '20px Roboto-Bold';
                ctx.textAlign = 'center';
                ctx.fillText(customNames[imageReference.id] ?? imageReference.name, x + symbolCardWidth / 2, y + symbolCardHeight - 24, symbolCardWidth - 16);
            }

            // Convert canvas to Blob and trigger download after all images are loaded
            Promise.all(imagePromises)
            .then(() => {
                canvas.toBlob(blob => {
                    // Trigger download
                    const a = document.createElement('a');
                    a.download = `${calloutSet?.name}.png`;
                    a.href = URL.createObjectURL(blob!);
                    a.click();
                    setIsGeneratingImage(false);
                });
            })
        });
}

As you can see, I utilize Promises to transition between each stage of the image generation process. However, I am encountering an issue where after generating the image once (or sometimes multiple times), the process fails to work again because the background.onload callback is not getting executed. This erratic behavior perplexes me, prompting the question of why it occurs and how can it be resolved?

Answer №1

When setting the onload callback of your background in the load call back of roboto, it's important to note that if your background loads before roboto, then the onload of the background will not be triggered.

The solution is simple: move your background.onload outside the roboto load resolver.

This code snippet has been adapted from your original code with the exclusion of roboto loading.

Please note that due to limitations on stackoverflow preventing image downloads, I am changing the url of the "result" img to a generated dataUrl for demonstration purposes. The code generates a new random imageList each time to demonstrate multiple uses without any issues, although minor error handling may be required.

const imgUrl = () => `https://picsum.photos/${100 + Math.round(100 + Math.random() * 100)}/${100 + Math.round(200 + Math.random() * 100)}`;


const backgroundImageSrc = imgUrl();
const FullLogo = document.getElementById('FullLogo');
function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.roundRect(x, y, w, h, r);
  ctx.stroke();
}
// just maps id to id
const customNames = [...'abcdefgh'].reduce((a, e) => { a[e] = e; return a; }, {});

let isGeneratingImage = false;
const downloadImage = () => {
    if (isGeneratingImage) return
    isGeneratingImage = true

    // random images
    const imageList = [
      { id: 'a', url: imgUrl() },
      { id: 'b', url: imgUrl() },
      { id: 'c', url: imgUrl() },
      { id: 'd', url: imgUrl() },
      { id: 'e', url: imgUrl() },
      { id: 'f', url: imgUrl() },
      { id: 'g', url: imgUrl() },
      { id: 'h', url: imgUrl() }
    ];
    // Define sizes for canvas components
    const canvasWidth = 1000;
    const logoHeight = 70;
    const logoMargin = 16;
    const symbolsPerRow = 6;
    const symbolCardWidth = 140;
    const symbolCardHeight = 175;
    const symbolCardGap = 8;
    const symbolImageSize = 96;

    // Calculate canvas height based on number of symbols
    // Symbols are arranged like a flexbox row with wrap
    const canvasHeight = Math.ceil(imageList.length / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);
    const canvasMargin = Math.ceil((canvasWidth - (symbolsPerRow * (symbolCardWidth + symbolCardGap)) + symbolCardGap) / 2);

    // Create canvas element in the html document
    const canvas = document.createElement('canvas');
    canvas.width = canvasWidth;
    canvas.height = canvasHeight;

    // Get 2d drawing context
    const ctx = canvas.getContext('2d');

    // Draw background image (same as the one used for the PageSection)
    const background = new Image();
    background.setAttribute('crossorigin', 'anonymous');
    background.src = backgroundImageSrc;
    
    background.onload = () => {
        // Calculate scaling factors to cover the canvas while maintaining aspect ratio
        const scaleX = canvasWidth / background.width;
        const scaleY = canvasHeight / background.height;
        const scale = Math.max(scaleX, scaleY);

        // Calculate the new width and height of the image
        const newWidth = background.width * scale;
        const newHeight = background.height * scale;

        // Calculate the position to center the image on the canvas
        const x = (canvasWidth - newWidth) / 2;
        const y = (canvasHeight - newHeight) / 2;

        // Draw the background image with the calculated parameters
        ctx.filter = 'brightness(0.4) blur(10px)';
        ctx.drawImage(background, x, y, newWidth, newHeight);

        // Reset filter
        ctx.filter = 'none';
        
        // List of promises for loading images
        const imagePromises = [];

        // Load the logo image
        const logo = new Image();
        logo.setAttribute('crossorigin', 'anonymous');
        logo.src = FullLogo.src;
        imagePromises.push(new Promise(resolve => {
            logo.onload = () => {
                // Calculate the scaled width to maintain aspect ratio
                const scaledWidth = (logoHeight / logo.naturalHeight) * logo.naturalWidth;

                // Draw logo horizontally centered with a margin at the top
                ctx.drawImage(
                    logo,
                    canvasWidth / 2 - scaledWidth / 2,
                    logoMargin,
                    scaledWidth,
                    logoHeight
                );
                resolve();
            }
        }));
        
        // Calculate values for drawing symbols in the last row
        const symbolsInLastRow = imageList.length % symbolsPerRow;
        const lastRowOffset = (symbolsPerRow - symbolsInLastRow) * (symbolCardWidth + symbolCardGap) / 2

        // Draw symbols with rounded backgrounds
        for (let i = 0; i < imageList.length; i++) {
            const imageReference = imageList[i];

            // If the symbol is in the last row, we need to adjust the x position to center it
            const isLastRow = i >= imageList.length - symbolsInLastRow;
        
            const x = (i % symbolsPerRow) * (symbolCardWidth + symbolCardGap) + symbolCardGap + canvasMargin + (isLastRow ? lastRowOffset : 0);
            const y = Math.floor(i / symbolsPerRow) * (symbolCardHeight + symbolCardGap) + symbolCardGap + logoHeight + (logoMargin * 2);

            // Draw transparent gray background for symbol with rounded borders
            ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
            roundRect(ctx, x, y, symbolCardWidth, symbolCardHeight, 16);

            // Draw symbol image
            const image = new Image();
            image.setAttribute('crossorigin', 'anonymous');
            image.src = imageReference.url + '  ';
            
            imagePromises.push(new Promise(resolve => {

                image.onload = () => {
                    ctx.drawImage(image, x + (symbolCardWidth - symbolImageSize) / 2, y + (symbolCardHeight - symbolImageSize) / 4, symbolImageSize, symbolImageSize);
                    resolve();
                }
            }));

            // Draw symbol name
            ctx.fillStyle = 'white';
            ctx.fontSize = '20px';
            ctx.fontFamily = "'Roboto', sans-serif;";
            ctx.textAlign = 'center';
            ctx.fillText(customNames[imageReference.id] ?? imageReference.name, x + symbolCardWidth / 2, y + symbolCardHeight - 24, symbolCardWidth - 16);
        }

        // Convert canvas to Blob and trigger download after all images are loaded
        Promise.all(imagePromises)
        .then(() => {
            
            const dataUrl = canvas.toDataURL();
            document.getElementById('result').src = dataUrl;
            
            isGeneratingImage = false;

            // Trigger download do not work on SO
            // const a = document.createElement('a');
            // a.download = `blabla.png`; // `
            // a.href = dataUrl;
            // a.click();
            
        });

    };
        
}

document.getElementById('dlbutton').addEventListener('click', downloadImage);
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@700&display=swap" rel="stylesheet">
<button id="dlbutton">download</button>
<img id="FullLogo" src="https://picsum.photos/200/300" crossorigin="anonymous" />

<img id="result" src="https://picsum.photos/640/480" crossorigin="anonymous" />

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Comparing the differences between while loops and setTimeout function in JavaScript

I'm currently deciding between using a while loop or a setTimeout function in JavaScript. As I understand it, due to JavaScript's single-threaded nature, having one function run for an extended period can hinder the execution of other functions s ...

How to troubleshoot passing json data from a controller to an AngularJS directive

I recently started learning AngularJS and I'm facing an issue with passing JSON data from the controller to a directive. When I try to display the data, nothing shows up and I encounter the following errors: 1. Uncaught SyntaxError: Unexpected token ...

Utilize Material UI's Datagrid or XGrid components to customize the rendering

There is a section from Material UI discussing renderHeader in the DataGrid and Xgrid components. https://material-ui.com/components/data-grid/columns/#render-header The documentation describes how to add additional content to the header, but what if I w ...

Javascript's second element does not trigger a click event with similar behavior

I'm currently facing an issue with displaying and hiding notification elements based on user interaction. My goal is to have multiple popup elements appear when the page loads. Then, when a user clicks the ".alert-close" element within one of the popu ...

Steps for deploying a Next.js frontend and a separate Express backend:1. First,

I am in the process of developing an application that utilizes next.js for the frontend and a standalone backend server built on express. As I plan for production deployment, I am concerned about costs and the most efficient approach to take. Although I ha ...

Injecting services into AngularJS controllers helps to provide the necessary dependencies

Greetings! Here is the code snippet for my service: angular.module('lucho1App').factory('ApiExample', ['$http', '$q', function($http, $q) { return { promiseToHaveData: function() { return $ht ...

What is the most effective way to loop through an object containing DOM selectors as values, and subsequently utilize them to assign new values?

I have a function that takes an object retrieved from a database as its argument. My goal is to display the values of this object in a form by associating each value with a specific DOM selector. Here is the code snippet where I achieve this: function pai ...

Utilize Javascript to generate intricate table headers based on JSON data

I am currently facing a challenge in creating an HTML table header with colspan. I have a JSON object as follows: var metadata = [{ "colIndex": 0, "colType": "String", "colName": "PM" }, { "colIndex": 1, "colType": "String", "colName": "PR ...

Notes on using touch devices with Angular

My goal is to make my Angular application capable of displaying a footnote on mobile devices when text injected with nativeElement.innerHTML is clicked. I attempted to explain this before but feel that I didn't do it justice, so here's another sh ...

Context API is failing to work in components that use children when the version is v16.6.0 or higher

Currently, I am utilizing the latest context API of React (v16.6.0 or higher) by specifying the public static contextType inside the component that consumes the context. Everything works smoothly unless the component declaring the Provider directly include ...

The use of Ajax post results in the retrieval of multiple arrays containing objects that possess various values

I have a PHP file (ajax.php) that retrieves messages from a database and a JavaScript file (main.js) that sends an AJAX request to this PHP file. My goal is to create a table row in the JS file for each message returned by the PHP file. Main.js: functio ...

Unable to physically tap on the checkbox input, though able to perceive the value it holds

When running my protractor test, I encountered an issue with the following statement: await element(by.model('publishCtrl.isPublishedInAllRegions')).click(); The test failed and returned an error message stating "ElementNotVisibleError: element ...

Facing an issue with Vercel Deployment: Public folder not found in App Router within the next 14

I recently created a file downloading API endpoint in my Next.js project that allows users to download files from specific folders within the public directory. Here is the code snippet: export async function GET(request: Request, { params }: GetParams) { ...

What is the best way to smoothly scroll to another page using a specific id?

My website consists of multiple pages and I am looking for a code that will allow for smooth scrolling navigation to another page when loading on a specific id or section. For example, in the navbar I have multiple pages with links. When clicking on a lin ...

Tips for identifying the version of a package that is installed using a package-lock.json file containing lockfileVersion = 3

After upgrading from Node 16 (npm 8) to Node 18 (npm 9), I noticed a difference in the structure of the package-lock.json files. Files generated with npm 8 have a lockfileVersion: 2, while those generated with npm 9 have a lockfileVersion: 3. The changes a ...

What could be the reason for the malfunctioning transition effect in my slider animation?

Having trouble getting my slider animation to work. I've tried different CSS styles but the slide transition is not functioning as expected. When one of the buttons is clicked, I want the slide to change from right to left or left to right. Can anyone ...

Issue: Reactjs - Material-UI HTML Tooltip does not display dynamic HTML content.In the Reactjs

I have been using a customized HTML MUI Tooltip. Currently, it is functioning with static content but I am looking to make it dynamic. Unfortunately, it is not working with dynamic HTML content. Here is my attempted approach: const ADJUSTMENT_HELP_TEXT = ...

What is the best way to display a component based on a certain condition?

There are two instances where the Items component needs to be displayed. However, in one instance an additional component, Filters, is required while in another it is not. The Filters component is nested inside Items. When attempting to implement this, ...

Setting the current date in Angular using TypeScript and storing it in a MySQL database

As I delve into learning Angular, I am focused on practicing with a form. Specifically, I am attempting to include the current date when inputting client records along with their RFC, branch, and cost. Unfortunately, my attempts have been unsuccessful in i ...

Transferring data between AngularJS and non-AngularJS environments

Within my AngularJS application, the $scope.mydata variable holds some important data within a controller. I am currently working on a regular JSP page without any AngularJS functionality, but I need to access the data stored in the scope variable (mydat ...