DEV Community

Underdog
Underdog

Posted on

From Base64 to S3 storage: Solving React-Quill Image Paste Performance Issues

The Problem
When building a rich text editor for content management, one common challenge is handling images pasted from sources like Microsoft Word or other websites. These images often come as base64-encoded strings embedded directly in the content.

The react-quill library, despite being a powerful rich text editor, doesn't handle this out of the box. By default, when users paste content with images (from Word, Google Docs, or web pages), react-quill preserves the base64-encoded images directly in the editor's content.

This means: While this works for small images, it creates significant performance issues:

Slow page loads: Articles with multiple base64 images can take 10+ seconds to load

Our editor was suffering from exactly this issue. Articles with embedded images were taking up to 10 seconds to load. After implementing our solution, load times dropped to under 0.5 seconds.

The Solution
We created a robust image handling parser that automatically intercepts pasted images, uploads them to S3 storage, and replaces the base64 data with cloud URLs. Here's how we achieved it.

Key Components
1. Registering Custom Matchers
The crucial first step is registering matchers in the Quill clipboard module. This tells Quill to intercept any pasted content containing images and process it through our custom handler:

const modules = useMemo(
  () => ({
    toolbar: {...},
    imageDropAndPaste: {...},
    clipboard: {
      matchers: [
        ['IMG', imgMatcher],      // Process img tags
        ['PICTURE', imgMatcher],  // Process picture tags
      ],
    },
  }),
  [],
)
Enter fullscreen mode Exit fullscreen mode

and pass it to props in React-quill component like this

<ReactQuill
      modules={modules}
      data-aid={testId}
      ref={quillRef}
/>
Enter fullscreen mode Exit fullscreen mode

Before uploading to S3, we need util function which convert base64 strings to File objects. But You can use any of your own utilities or third-party libraries for base64 to File conversion.

const getContentTypeFromBase64 = (dataUrl: string) => {
  if (!dataUrl.startsWith('data:')) return null

  const match = dataUrl.match(/^data:(.*?);base64,/)
  return match ? match[1] : null
}


export const base64toFile = ({
  base64,
  name,
}: {
  base64: string
  name?: string
}): File => {
  // Extract content type from base64 header
  const contentType = base64.match(/^data:(.*?);base64,/)?.[1] || 'image/png'

  // Decode base64 to binary data
  const byteString = atob(base64.split(',')[1] || base64)
  const byteArray = new Uint8Array(byteString.length)

  for (let i = 0; i < byteString.length; i++) {
    byteArray[i] = byteString.charCodeAt(i)
  }

  return new File([byteArray], name ?? uuidv4(), { type: contentType })
}

Enter fullscreen mode Exit fullscreen mode

then look up at our function that parse it from base64 to s3, get new urls from s3 storage and implement it into our editor

import Delta from 'quill-delta'
import { base64toFile } from '/path/to/util'
import ReactQuill from 'react-quill-new'

export const imageMatcherUtil = (
  handleImageUpload: (file: File) => Promise<string | undefined>,
  quillRef: React.RefObject<ReactQuill | null>,
) => {
  return (_: void, delta: Delta) => {
    // Step 1: Filter for base64 images in the pasted content
    const base64Images = delta.ops.filter(
      (op) =>
        typeof op.insert === 'object' &&
        typeof op.insert.image === 'string' &&
        'image' in op.insert &&
        op.insert?.image?.startsWith('data:'),
    )

    // Step 2: If no base64 images or no upload handler, return original delta
    if (base64Images.length === 0 || !handleImageUpload) {
      return delta
    }

    // Step 3: Process each base64 image asynchronously
    base64Images.forEach((op) => {
      const insert = op.insert as Record<string, string>
      const base64File = insert.image

      // Convert base64 string to File object
      const parsedFile = base64toFile({ base64: base64File })

      // Upload to cloud storage
      handleImageUpload(parsedFile).then((url) => {
        if (url && quillRef.current) {
          const quill = quillRef.current.getEditor()
          const contents = quill.getContents()
          let position = 0

          // Step 4: Find and replace each base64 image with cloud URL
          for (const contentOp of contents.ops) {
            if (
              typeof contentOp.insert === 'object' &&
              contentOp.insert?.image === base64File
            ) {
              // Replace base64 with cloud URL in the document
              quill.updateContents(
                new Delta().retain(position).delete(1).insert({ image: url }),
              )
              break
            }
            // Track position for accurate replacement
            position +=
              typeof contentOp.insert === 'string' ? contentOp.insert.length : 1
          }
        }
      })
    })

    // Return original delta - the async replacement happens separately
    return delta
  }
}
Enter fullscreen mode Exit fullscreen mode

That's it !

Top comments (0)