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?