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
],
},
}),
[],
)
and pass it to props in React-quill component like this
<ReactQuill
modules={modules}
data-aid={testId}
ref={quillRef}
/>
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 })
}
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
}
}
That's it !
Top comments (0)