Building an Image Merge Tool with Canvas: Horizontal and Vertical Concatenation Algorithms#

A deep dive into implementing a multi-image merger using native Canvas API, covering parallel loading, dimension calculation, and drag-and-drop upload.

Introduction#

If you’ve ever needed to combine multiple screenshots into a single long image, you know the pain of using Photoshop or online tools with their upload-download cycles. The good news? You can build this with pure Canvas API in under 50 lines of code.

Let’s explore the technical implementation of an image merge tool.

Core Technical Concepts#

1. Parallel Image Loading#

The first step is loading all images before merging. Instead of nested callbacks, Promise.all with the Image object provides an elegant solution:

const loadedImages = await Promise.all(
  images.map((src) => {
    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => resolve(img);
      img.src = src;
    });
  })
);

Key points:

  • Each image loads as a Promise
  • Promise.all waits for all images to complete
  • Returns an array of HTMLImageElement ready for ctx.drawImage

2. Dimension Calculation for Horizontal Merging#

Horizontal concatenation arranges images left to right in a single row. Two values matter:

  • Total Width: Sum of all image widths
  • Canvas Height: Maximum height among all images
// Horizontal merge
const totalWidth = loadedImages.reduce((sum, img) => sum + img.width, 0);
const maxHeight = Math.max(...loadedImages.map((img) => img.height));

canvas.width = totalWidth;
canvas.height = maxHeight;

let x = 0;
loadedImages.forEach((img) => {
  ctx.drawImage(img, x, 0);  // Draw from left to right
  x += img.width;            // Update x coordinate
});

A subtle detail: ctx.drawImage(img, x, 0) uses y=0, meaning all images align at the top. For bottom alignment, use y = maxHeight - img.height.

3. Vertical Merge Implementation#

Vertical merging swaps x for y and inverts width/height calculations:

// Vertical merge
const maxWidth = Math.max(...loadedImages.map((img) => img.width));
const totalHeight = loadedImages.reduce((sum, img) => sum + img.height, 0);

canvas.width = maxWidth;
canvas.height = totalHeight;

let y = 0;
loadedImages.forEach((img) => {
  ctx.drawImage(img, 0, y);  // Draw from top to bottom
  y += img.height;           // Update y coordinate
});

4. Drag-and-Drop Upload#

For better UX, drag-and-drop beats clicking a button:

const handleDrop = (e) => {
  e.preventDefault();  // Prevent browser's default file-open behavior
  const files = e.dataTransfer.files;
  // Process files...
};

<div
  onDrop={handleDrop}
  onDragOver={(e) => e.preventDefault()}  // Must prevent dragOver, else drop won't fire
>
  Drop images here
</div>

Convert files to base64 DataURL for state storage:

const reader = new FileReader();
reader.onload = (e) => {
  setImages((prev) => [...prev, e.target.result]);
};
reader.readAsDataURL(file);

Performance Considerations#

Memory Management#

Large image merging can consume significant memory. toDataURL() generates a new base64 string that could be tens of MB for large images. Recommendations:

  1. Clear the original image array after merging
  2. Use URL.createObjectURL(blob) instead of base64 (smaller footprint)
  3. For extremely long images (like stitched mobile screenshots), consider chunked processing

Format Selection#

PNG is the default export format, but large dimensions mean large files. JPEG compression helps:

// Quality 0.8 cuts size in half
canvas.toDataURL('image/jpeg', 0.8);

Real-World Use Cases#

  • Screenshot stitching: Combine chat history into a long image
  • Side-by-side comparison: Compare design mockups with implementations
  • Photo grids: Create 3x3 grids for social media
  • Product showcases: Stitch multiple product photos into detail pages

Try it live: Image Merge Tool


Originally published on JsonKit Technical Blog.