npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 statevalue + 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-editor

React 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 image

Custom 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 own className to 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 !important and 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-check

The 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:

  1. Bump version in package.json.
  2. git commit -am "chore(release): 0.x.y"
  3. 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.