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

What could be causing React to retain the value in the form input within my modal?

Currently in my project, I am utilizing Next.js along with Tailwind CSS and Daisy UI. The functionality on the page includes fetching a JSON object from an API endpoint and displaying two tables: one for source systems and another for attached domains. Cl ...

How to efficiently monitor and calculate changes in an array of objects using Vue?

I have a collection named people that holds data in the form of objects: Previous Data [ {id: 0, name: 'Bob', age: 27}, {id: 1, name: 'Frank', age: 32}, {id: 2, name: 'Joe', age: 38} ] This data can be modified: New ...

Automatically tally up the pages and showcase the page numbers for seamless printing

I've been tackling a challenge in my Vue.js application, specifically with generating invoices and accurately numbering the pages. An issue arose when printing the invoice – each page was labeled "Page 1 of 20" irrespective of its actual position in ...

Transform Dynamic Array to JSON structure

I am currently developing a feature in my SvelteKit application that allows users to create custom roles for themselves. Users can input a role name and add it to an array, which is then displayed below. https://i.stack.imgur.com/oUPFU.png My goal is to ...

Material UI Snackbar background color not able to be changed

Currently, I'm working on an ErrorHandler component in React.JS that displays a Material UI Snackbar whenever it catches an error. The issue I'm facing is trying to change the background color of the Snackbar to red, which seems to be problematic ...

Position the Bootstrip 4 tooltip to automatically display on the right side

In order to customize the placement of my tooltip on desktop, I have opted for having it positioned on the right side of the elements. While this choice aligns well with my design preferences, it has presented a challenge when viewing the page on smaller s ...

Transmitting information from JavaScript to a PHP script

I am currently using a function that grabs text from a PHP file on our server and inserts it into an HTML page. However, I now need to modify this function in order to SEND data (specifically a couple of JavaScript variables) to the PHP file instead of si ...

I have a quick question: What is the most effective method for creating PDF templates with Angular and .NET 6, specifically for designs that feature heavy

Seeking the optimal solution for creating PDF templates using Angular and .NET 6? Specifically looking to design templates that heavily feature tables. In my exploration of efficient PDF template creation with Angular and .NET 6, I ventured into using pdf ...

I am having trouble with node.js express not recognizing the path to my CSS files

My objective is to load information onto an HTML page using Node.js and Express. The issue I am facing is that when I try to open the main page (which displays all the books from the database), everything works smoothly - the CSS and JS files are located ...

The console is being flooded with API logging messages multiple times

My goal is to develop a search page for Pathfinder. I have crafted the following code in an attempt to retrieve data from the API. During the process of testing the fetch requests, I have noticed that when I console.log the fetched data, it appears multipl ...

Information vanishes as the element undergoes modifications

I am currently working with a JSON file that contains information about various events, which I am displaying on a calendar. Whenever an event is scheduled for a particular day, I dynamically add a div element to indicate the presence of an event on the c ...

What is the process for accessing various font weights in NextJS?

I'm stuck trying to figure out what I'm messing up. I have this code in mind: <link href="https://fonts.googleapis.com/css2family=Montserrat:wght@300;400;200&display=swap" rel="preload" as="font" crossOrigin=& ...

Verify role declarations and display components if valid

I am currently developing an application using Angular on the front-end and ASP.NET on the back-end. Within this application, there are two roles: user and admin. I have implemented a navigation bar with several buttons that I need to hide based on the use ...

What is the best way to conceal elements that do not have any subsequent elements with a specific class?

Here is the HTML code I have, and I am looking to use jQuery to hide all lsHeader elements that do not have any subsequent elements with the class 'contact'. <div id="B" class="lsHeader">B</div> <div id="contact_1" class="contac ...

Assigning a value to a JavaScript variable using Ajax

Struggling with a problem for hours and still can't find the issue... A registration form is set up for users to create accounts. When the submit button is clicked, a validateForm function is triggered. Within this function, some JavaScript tests ar ...

What could be the reason why the session is not defined within the _app

I am facing an issue while trying to conditionally add a <Layout> component based on the user's login status. My initial thought was to handle this inside the _app component since the Layout is used across all pages. However, I'm encounter ...

What is the functionality of this.$eval in Vue.js?

During the process of updating an unfamiliar old script from version 0.11 to 2.5.4, an alert popped up stating: To address the warning message saying 'Replace this.$eval('reportData | reportFilter false') with a solution using normal Java ...

Access to property 'foo' is restricted to an instance of the 'Foo' class and can only be accessed within instances of 'Foo'

In my Typescript code, I encountered an error with the line child._moveDeltaX(delta). The error message reads: ERROR: Property '_moveDeltaX' is protected and only accesible through an instance of class 'Container' INFO: (me ...

Issue with conflicting namespaces warning when compiling an Angular 9 project with ng-packagr using Typescript

I'm trying to pinpoint the root cause of this problem, and I suspect it may be related to Typescript. However, it could also involve ng-packagr or Angular. This issue only arose after upgrading to Angular 9. Upon building my production environment, I ...

How can we eliminate the need for specifying the order of generic arguments in TypeScript?

In the development of my middleware engine, I have incorporated various generic arguments that are specific to the particular implementation in use. export type Middleware< Store = never, Args = unknown, Response = unknown > = ( context: { ...