I have an image generation button set up and working using Nextjs and the HTML canvas element, which (almost) works beautifully. When a user clicks the “Generate Image” button, it generates an image with a bunch of smaller images in it with labels underneath each one.
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);
});
})
});
}
Notice how I use Promises to move between each step of the image generation process after the font is loaded, then after the background image is loaded, then after all the smaller images have loaded.
However, the issue is that after the image is generated once (or sometimes several times), it will not work the second time because the background.onload callback is never called, thus the following steps are never executed (I have tested this with console logs). Why is this erratic behavior happening, and how can I fix it?