ape-rich-text-editor
v0.4.0
Published
Full-featured TipTap-based rich text editor for React. Produces JSONContent compatible with ape-rich-text-renderer.
Downloads
1,280
Readme
ape-rich-text-editor
Full-featured TipTap-based rich text editor for React. Produces JSONContent consumable by ape-rich-text-renderer.
- Pluggable image upload — wire it to S3, Firebase, Supabase, Cloudinary, or your own backend in one prop.
- No Tailwind required at consumer level — styles ship pre-compiled in
dist/style.css. - Word paste sanitization — pastes from Microsoft Word are cleaned to safe HTML automatically.
- Tables, images, color, alignment, lists, code, links, headings — out of the box.
- Single component, controlled state —
value+onChange, like any form input.
Install
pnpm add ape-rich-text-editor
# or
npm install ape-rich-text-editor
# or
yarn add ape-rich-text-editorReact 18 or 19 is required (declared as a peer dependency).
Quick start
import { useState } from 'react'
import { RichTextEditor, type JSONContent } from 'ape-rich-text-editor'
import 'ape-rich-text-editor/style.css'
export function ContentForm() {
const [content, setContent] = useState<JSONContent | undefined>()
return (
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Escribí el contenido aquí..."
/>
)
}That's it. The CSS import is required once per app (typically at your entry point).
Saving and loading content
The editor's source of truth is a JSONContent object — TipTap's internal document format. Persist it as JSON in your database, send it over the wire, and load it back with no transformations.
// Save
const handleSubmit = async () => {
await fetch('/api/articles', {
method: 'POST',
body: JSON.stringify({ body: content }),
})
}
// Load
useEffect(() => {
fetch('/api/articles/42')
.then(r => r.json())
.then(article => setContent(article.body))
}, [])To display the saved content (read-only) without bundling the full editor, use the renderer:
import { RichTextRenderer } from 'ape-rich-text-renderer'
import 'ape-rich-text-renderer/style.css'
;<RichTextRenderer content={article.body} />The editor and renderer share the exact same visual style — true WYSIWYG.
Image upload (production wiring)
By default, when onImageUpload is not provided, images are inlined as base64 data URLs. This is fine for demos and prototypes but NOT for production — it bloats the JSON, slows queries, and breaks CDNs.
For production, pass an onImageUpload callback that uploads the File to your storage and resolves with the public URL. The callback is invoked for every image insertion path: paste, drag-and-drop, and the file-picker tab of the image popover.
onImageUpload?: (file: File) => Promise<string>
// ↑ browser File ↑ public URL of the uploaded imageCustom backend (REST endpoint)
const handleUpload = async (file: File): Promise<string> => {
const formData = new FormData()
formData.append('image', file)
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
credentials: 'include',
})
if (!res.ok) throw new Error('Upload failed')
const { url } = await res.json()
return url
}
;<RichTextEditor onImageUpload={handleUpload} value={content} onChange={setContent} />Firebase Storage
import { getStorage, ref, uploadBytes, getDownloadURL } from 'firebase/storage'
const handleUpload = async (file: File): Promise<string> => {
const storage = getStorage()
const path = `cms/${Date.now()}-${file.name}`
const snapshot = await uploadBytes(ref(storage, path), file)
return await getDownloadURL(snapshot.ref)
}Supabase Storage
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
const handleUpload = async (file: File): Promise<string> => {
const path = `cms/${Date.now()}-${file.name}`
const { error } = await supabase.storage.from('images').upload(path, file)
if (error) throw error
return supabase.storage.from('images').getPublicUrl(path).data.publicUrl
}AWS S3 with presigned URLs (recommended for production)
The browser uploads the file directly to S3 using a short-lived signed URL — your backend never touches the binary, you save CPU and bandwidth, and the AWS secret never reaches the client.
const handleUpload = async (file: File): Promise<string> => {
// 1. Backend signs a one-shot upload URL
const { uploadUrl, publicUrl } = await fetch('/api/sign-s3-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, type: file.type }),
}).then(r => r.json())
// 2. Browser uploads directly to S3
const res = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
})
if (!res.ok) throw new Error('S3 upload failed')
// 3. The public URL is what gets stored in the JSON
return publicUrl
}Pair this with @aws-sdk/s3-request-presigner on the server to sign the URL.
Cloudinary (unsigned upload preset)
const handleUpload = async (file: File): Promise<string> => {
const formData = new FormData()
formData.append('file', file)
formData.append('upload_preset', 'YOUR_UNSIGNED_PRESET')
const res = await fetch(`https://api.cloudinary.com/v1_1/YOUR_CLOUD_NAME/image/upload`, {
method: 'POST',
body: formData,
})
const { secure_url } = await res.json()
return secure_url
}Anti-patterns
// ❌ DO NOT DO THIS — blob URL dies when the tab closes
onImageUpload={async (file) => URL.createObjectURL(file)}
// ❌ DO NOT DO THIS — defeats the purpose of the callback
onImageUpload={async (file) => {
return new Promise(r => {
const reader = new FileReader()
reader.onload = () => r(reader.result as string) // base64
reader.readAsDataURL(file)
})
}}If your callback throws, the image is silently NOT inserted (and the error is logged to the console). Make sure to surface upload errors to the user via your own UI/toast layer if needed.
Props
| Prop | Type | Description |
| --------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| value | JSONContent \| undefined | Controlled editor content. Use undefined for empty. |
| onChange | (c: JSONContent) => void | Fired on every edit. Receives the full document JSON. |
| placeholder | string | Placeholder text when empty. Default: "Escribe el contenido de tu noticia aquí...". |
| className | string | Extra classes for the wrapping <div>. Useful for layout (height, max-width). |
| onImageUpload | (file: File) => Promise<string> | Recommended for production. Called on every image insert (paste, drop, file picker). Must resolve to a hosted URL. |
Features
- Headings (h1, h2, h3) via the toolbar dropdown.
- Inline marks: bold, italic, underline, strike, inline code, text color, highlight color.
- Block marks: blockquote, code block, bullet list, ordered list, horizontal rule.
- Alignment: left, center, right, justify (works on paragraphs and headings).
- Links: insert with popover (URL + display text + target).
- Tables: insert N×M, resize columns, merge/split cells, color cells, align cell content. Right-click for context menu.
- Images: paste, drag-and-drop, or file picker. Resize via handles. Float left/right or stretch.
- Word paste cleanup: HTML pasted from Microsoft Word is sanitized automatically.
Bundle size
| | Minified | Min + gzip |
| ---------------------- | -------- | ---------- |
| ape-rich-text-editor | ~250 KB | ~75 KB |
The full editor includes TipTap, ProseMirror, and Radix UI primitives. If you only need to display content, install ape-rich-text-renderer instead — that one is ~5 KB minified.
A typical CMS application bundles both:
- The editor on the admin/draft pages.
- The renderer on the public-facing site.
Round-trip with the renderer
The editor produces JSON. The renderer consumes that exact JSON. No transformation, no schema mapping, no markdown conversion.
┌──────────────┐ editor.getJSON() ┌─────────┐ loadFromDB ┌────────────────┐
│ RichText │ ───────────────────► │ DB │ ──────────────► │ RichText │
│ Editor │ │ JSON │ │ Renderer │
└──────────────┘ └─────────┘ └────────────────┘Visual fidelity is guaranteed because both packages share the same heading sizes, list styles, code block rendering, etc. If you spot drift, file an issue.
Localization
v0.1.0 ships with Spanish UI strings hardcoded (Negrita, Insertar imagen, Color de texto, etc.). Per-string customization via a labels prop is planned for v0.2.0.
Tailwind compatibility
The lib does not require the consumer to install Tailwind. Styles ship pre-compiled in dist/style.css.
How isolation works
dist/style.css is post-processed at build time so every rule is scoped under the .ape-rte-editor wrapper class. Importing the stylesheet anywhere in your app will not touch elements outside the editor:
- Tailwind utilities (
.flex,.bg-white,.text-sm, hover/focus variants, etc.) are wrapped in:where(.ape-rte-editor)— specificity stays at (0,1,0), identical to a bare Tailwind utility. The consumer can override any of them by passing their ownclassNameto the editor and source order will decide the winner. - Tailwind v4's engine-variable reset (
*, :before, :after, ::backdrop { --tw-…: 0 }) is rewritten to apply only to descendants of.ape-rte-editor, so the consumer's--tw-*variables stay untouched. - The Preflight base reset (margins/font-family on
*,h1-h6,a,button, lists) was already removed in 0.2.1 and is not shipped at all.
Radix popovers, selects, and context menus inside the editor are automatically reparented into a sibling <div class="ape-rte-editor ape-rte-portal-root"> that the editor mounts on document.body. That div carries the scope class so the scoped CSS still applies to portalled UI without being clipped by the editor's rounded chrome.
Tradeoffs to know
- A consumer's global utility rule (e.g.
.flex { gap: 99px }in their own CSS) will still affect descendants inside the editor because:where()has specificity 0. If you need absolute isolation from the consumer's own Tailwind utilities, scope them in your app, not globally. !-prefixed Tailwind variants (the library doesn't currently use them) emit!importantand would beat the consumer's overrides — expected behaviour.
Development
pnpm install
pnpm dev # opens the playground at http://localhost:3335 (split view: editor | live JSON)
pnpm build # builds dist/index.{js,cjs,d.ts} and dist/style.css
pnpm type-checkThe dev/ playground mounts the editor with sample content and shows the live JSON output side-by-side. Use it to validate UX changes and to debug JSON shape issues.
Release
Tag-driven release. To publish a new version:
- Bump
versioninpackage.json. git commit -am "chore(release): 0.x.y"git tag v0.x.y && git push --tags
The GitHub Action builds and publishes to npm. Set the NPM_TOKEN repository secret first.
License
UNLICENSED — internal use within APE-SENA-2025.
