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

tiptop-editor

v3.1.0

Published

Notion-like editor built with Tiptap v3 and HeroUI

Readme

Tiptop Editor

A Notion-like rich text editor built with Tiptap v3, HeroUI, and Tailwind CSS, packaged as a plug-and-play React component.

Inspired by Tiptap's Notion-like editor template: https://tiptap.dev/docs/ui-components/templates/notion-like-editor

npm version bundle size license

Features

  • Tiptap v3 editor with a ready-to-use Notion-like UI
  • Slash commands for inserting blocks
  • Table support with row, column, header, split, and merge actions
  • Emoji suggestions triggered with :
  • Built-in image uploader block
  • Text formatting, lists, code blocks, highlights, alignment, subscript, and superscript
  • TypeScript support

Installation

npm install tiptop-editor

Setup

This package requires HeroUI v3. Add its styles import to your CSS entry file:

@import "tailwindcss";
@import "@heroui/styles";

No HeroUIProvider wrapper is needed in your app — HeroUI v3 works without a root provider.

If you use the toast notifications, render Toast.Provider once near the root of your app:

import { Toast } from '@heroui/react'

export function App() {
  return (
    <>
      <YourApp />
      <Toast.Provider placement="top end" />
    </>
  )
}

Basic Usage

import { TiptopEditor } from 'tiptop-editor'
import 'tiptop-editor/dist/tiptop-editor.css'

export function Editor() {
  return (
    <TiptopEditor
      editorOptions={{
        content: '<p>I am the Tiptop Editor</p>',
        immediatelyRender: false,
      }}
    />
  )
}

editorOptions accepts the same options as useEditor from @tiptap/react, except extensions, which is managed internally by the package. To add your own extensions, use editorOptions.extraExtensions.

Editor Ref and Events

You can access the editor instance and bind Tiptap runtime event listeners through the component ref.

import { useEffect, useRef } from 'react'
import { TiptopEditor, type TiptopEditorHandle } from 'tiptop-editor'

export function EditorWithEvents() {
  const editorRef = useRef<TiptopEditorHandle>(null)

  useEffect(() => {
    const handleUpdate = ({ editor }: { editor: NonNullable<ReturnType<TiptopEditorHandle['getEditor']>> }) => {
      console.log(editor.getHTML())
    }

    editorRef.current?.on('update', handleUpdate)

    return () => {
      editorRef.current?.off('update', handleUpdate)
    }
  }, [])

  return <TiptopEditor ref={editorRef} />
}

Available ref methods:

  • getEditor()
  • on(event, callback)
  • off(event, callback?)
  • once(event, callback)

Extending the Editor

The package now supports two extension points:

  • editorOptions.extraExtensions Appends custom Tiptap extensions after the built-in set.
  • slots Lets you inject custom React UI around the editor and inside the selection menus.

Add custom Tiptap extensions

import { Extension } from '@tiptap/core'
import { TiptopEditor } from 'tiptop-editor'

const MyExtension = Extension.create({
  name: 'myExtension',
})

export function EditorWithExtraExtensions() {
  return (
    <TiptopEditor
      editorOptions={{
        immediatelyRender: false,
        extraExtensions: [MyExtension],
      }}
    />
  )
}

extraExtensions is additive only. If you pass an extension with the same name as one of the built-in extensions, the editor will warn in the console and show a toast because duplicate extension names can lead to unstable behavior.

Add custom UI with slots

Supported slots:

  • editorTop
  • editorBottom
  • selectionMenuPrepend
  • selectionMenuAppend
  • tableMenuPrepend
  • tableMenuAppend
  • dragHandleDropdown

Each slot accepts either:

  • a React node
  • a render function receiving { editor }
<TiptopEditor
  slots={{
    editorTop: ({ editor }) => (
      <button onClick={() => editor.chain().focus().insertContent('<p>Draft</p>').run()}>
        Insert draft
      </button>
    ),
  }}
/>

The dragHandleDropdown slot injects additional items into the block drag-handle dropdown. The slot content must be wrapped in a <Dropdown.Section>:

import { Dropdown, Label } from '@heroui/react'

<TiptopEditor
  slots={{
    dragHandleDropdown: ({ editor }) => (
      <Dropdown.Section>
        <Dropdown.Item id="ai_rewrite" textValue="AI Rewrite" onPress={() => console.log('AI rewrite', editor)}>
          <Label>AI Rewrite</Label>
        </Dropdown.Item>
      </Dropdown.Section>
    ),
  }}
/>

Use the editor context hook

For slotted components, you can consume the current editor instance through useTiptopEditor() instead of passing editor down manually.

import { TiptopEditor, useTiptopEditor } from 'tiptop-editor'

function AiToolbar() {
  const editor = useTiptopEditor()

  if (!editor) {
    return null
  }

  return (
    <button onClick={() => editor.chain().focus().insertContent('<p>AI draft</p>').run()}>
      Insert draft
    </button>
  )
}

export function EditorWithSlots() {
  return (
    <TiptopEditor
      slots={{
        editorTop: <AiToolbar />,
      }}
    />
  )
}

Custom Editor UI Options

TiptopEditor also supports a few package-specific options inside editorOptions:

<TiptopEditor
  editorOptions={{
    content: '<p>Custom layout</p>',
    disableDefaultContainer: true,
    showDragHandle: false,
  }}
/>
  • disableDefaultContainer Disables the default HeroUI Card wrapper and removes the editor's built-in padding. Use this when you want the editor to live inside your own container/layout.
  • showDragHandle Controls whether the block drag handle is rendered. Default: true.
  • extraExtensions Appends custom Tiptap extensions after the built-in editor set.
  • imgUploadUrl The URL of the server endpoint that receives image uploads.
  • imgUploadResponseKey Locates the image URL in the server response. Accepts a top-level key, a dot-separated path, a path array, or a resolver function.
  • imgUploadHeaders Custom HTTP headers sent with every image upload request (e.g. Authorization).

Built-in Extensions

The package ships with these extensions enabled out of the box:

  • StarterKit
  • ListKit
  • Placeholder
  • custom slash command menu
  • custom code block
  • custom horizontal rule
  • TextStyle and Color
  • Highlight
  • TextAlign
  • Subscript
  • Superscript
  • emoji suggestions
  • TableKit
  • image uploader block and upload handler

Tables

Type /table to insert a table.

Inside a table you can:

  • add or remove rows
  • add or remove columns
  • toggle header row or header column
  • split a merged cell
  • merge adjacent selected cells

To merge cells, drag across adjacent cells first, then use the table controls.

Emoji

Type : followed by an emoji name to open emoji suggestions.

Image Extension

The image feature is built around an imageUploader block.

How to insert an image block

  • Type /image
  • or use the slash menu and select Image

Once inserted, the block lets the user click to upload or drag and drop an image.

Supported files

  • image/png
  • image/jpeg
  • image/jpg
  • max size: 5MB

Demo mode with no backend

If you do not provide upload options, the editor simulates an upload and displays the image using a local object URL. This is useful for local demos and prototypes.

<TiptopEditor
  editorOptions={{
    content: '<p>Upload demo</p>',
  }}
/>

Real upload mode

To upload files to your backend, set both imgUploadUrl and imgUploadResponseKey.

<TiptopEditor
  editorOptions={{
    content: '<p>Upload to my API</p>',
    imgUploadUrl: '/api/upload',
    imgUploadResponseKey: 'url',
  }}
/>

The editor sends a POST request with multipart/form-data and the file under the file field.

imgUploadResponseKey is flexible. It supports:

  • a top-level key like 'url' Example:
    <TiptopEditor
      editorOptions={{
        imgUploadUrl: '/api/upload',
        imgUploadResponseKey: 'url',
      }}
    />
  • a nested path like 'data.url' Example:
    <TiptopEditor
      editorOptions={{
        imgUploadUrl: '/api/upload',
        imgUploadResponseKey: 'data.url',
      }}
    />
  • a path array like ['data', 'url'] Example:
    <TiptopEditor
      editorOptions={{
        imgUploadUrl: '/api/upload',
        imgUploadResponseKey: ['data', 'url'],
      }}
    />
  • a resolver function Example:
    <TiptopEditor
      editorOptions={{
        imgUploadUrl: '/api/upload',
        imgUploadResponseKey: (response) => {
          const asset = response.asset as { cdnUrl?: string } | undefined
          return asset?.cdnUrl
        },
      }}
    />

Sending custom headers

Use imgUploadHeaders to attach custom HTTP headers to every upload request. This is the standard way to pass an authorization token or any other API header.

<TiptopEditor
  editorOptions={{
    imgUploadUrl: '/api/upload',
    imgUploadResponseKey: 'url',
    imgUploadHeaders: {
      Authorization: 'Bearer YOUR_TOKEN',
    },
  }}
/>

Multiple headers are supported:

imgUploadHeaders: {
  Authorization: 'Bearer YOUR_TOKEN',
  'X-Api-Key': 'YOUR_API_KEY',
}

Note: The editor sends multipart/form-data. Do not include a Content-Type header in imgUploadHeaders — the browser sets it automatically with the correct boundary string.

Your server response must include the uploaded image URL at the location you describe with imgUploadResponseKey.

Example:

{
  "data": {
    "url": "https://cdn.example.com/uploads/image-123.jpg"
  }
}

Comments

The package ships a full inline and block comment system designed for review / view mode. Comments are stored entirely in the calling app — the package exposes hooks and callbacks so you can persist and sync with your own API.

Comment types

| Type | What it annotates | How it looks | |------|------------------|--------------| | inline | A selected text range | Yellow underline highlight on the text | | node | An entire block (paragraph, heading, …) | Left amber border on the block |

Quick setup

Wrap the editor in CommentsProvider, pass showCommentMenu: true and editable: false to put the editor into view mode, then render your own comment drawer next to it.

import { TiptopEditor, CommentsProvider, useComments, useCommentActions } from 'tiptop-editor'
import { Dropdown, Label } from '@heroui/react'
import { MessageSquarePlus } from 'lucide-react'
import { useTiptopEditor } from 'tiptop-editor'

// Optional: lets users add block-level comments from the drag-handle menu
function NodeCommentButton() {
  const editor = useTiptopEditor()
  const comments = useComments()

  if (!editor || !comments) return null

  return (
    <Dropdown.Section>
      <Dropdown.Item
        id="add_node_comment"
        textValue="Add comment"
        onPress={() => {
          comments.setPendingComment({
            id: crypto.randomUUID(),
            type: 'node',
            nodePos: editor.state.selection.from,
          })
        }}
      >
        <MessageSquarePlus size={16} />
        <Label>Add comment</Label>
      </Dropdown.Item>
    </Dropdown.Section>
  )
}

export function ReviewPage() {
  return (
    <CommentsProvider>
      <div style={{ display: 'flex' }}>
        <TiptopEditor
          editorOptions={{
            content: '<p>Document content…</p>',
            immediatelyRender: false,
            editable: false,
            showCommentMenu: true,
          }}
          slots={{
            dragHandleDropdown: <NodeCommentButton />,
          }}
        />
        {/* Your own drawer component here — see "Building a custom comment drawer" below */}
      </div>
    </CommentsProvider>
  )
}

showCommentMenu: true registers the CommentMark and NodeCommentExtension Tiptap extensions and renders the floating "Add comment" button that appears on text selection.

Persisting comments with your API

CommentsProvider accepts an onCommentsChange callback that fires whenever the comment list changes. Use it to sync with your backend.

import { useState } from 'react'
import { CommentsProvider, TiptopEditor } from 'tiptop-editor'
import type { TiptopComment } from 'tiptop-editor'

export function ReviewPage() {
  const [initialComments] = useState<TiptopComment[]>([
    // Comments loaded from your API on mount
  ])

  const handleCommentsChange = async (comments: TiptopComment[]) => {
    await fetch('/api/documents/123/comments', {
      method: 'PUT',
      body: JSON.stringify(comments),
    })
  }

  return (
    <CommentsProvider
      initialComments={initialComments}
      onCommentsChange={handleCommentsChange}
    >
      <div style={{ display: 'flex' }}>
        <TiptopEditor
          editorOptions={{
            content: '<p>…</p>',
            editable: false,
            showCommentMenu: true,
            immediatelyRender: false,
          }}
        />
        {/* Your own drawer here */}
      </div>
    </CommentsProvider>
  )
}

onCommentsChange receives the full updated array after every add, resolve, reply, or delete. It fires synchronously after the state update — debounce or batch API calls on your side as needed.

Loading existing comments

Pass your persisted comments as initialComments to CommentsProvider. The comment marks in the editor HTML already carry a data-comment-id attribute, so the sidebar threads reconnect automatically as long as the IDs match.

The editor content (HTML string with mark attributes) and the comments array are two separate things to persist. Store both and restore both — the HTML goes into editorOptions.content, the comments go into initialComments.

Reading comment state programmatically

Use useComments() anywhere inside CommentsProvider to read or mutate the comment state directly.

import { useComments } from 'tiptop-editor'

function CommentCount() {
  const comments = useComments()
  if (!comments) return null

  const open = comments.comments.filter(c => !c.resolved).length
  return <span>{open} open comment{open !== 1 ? 's' : ''}</span>
}

Available values and actions from useComments():

| Name | Type | Description | |------|------|-------------| | comments | TiptopComment[] | All comments (open and resolved) | | activeCommentId | string \| null | ID of the currently focused comment | | pendingComment | PendingComment \| null | Comment being drafted (not yet submitted) | | addComment | (id, type, content, author?) => void | Finalize a pending comment | | removeComment | (id) => void | Delete a comment from the context (use useCommentActions().remove to also strip the editor mark) | | resolveComment | (id) => void | Mark as resolved in the context (use useCommentActions().resolve to also strip the editor mark) | | replyToComment | (commentId, content, author?) => void | Append a reply | | setActiveCommentId | (id \| null) => void | Highlight a comment thread | | setPendingComment | (PendingComment \| null) => void | Open the new-comment form | | getComment | (id) => TiptopComment \| undefined | Look up a single comment |

Building a custom comment drawer

The package does not ship a styled sidebar — you build your own. The only non-obvious part is that resolving or deleting a comment requires two coordinated steps: removing the mark from the editor and updating the context. The useCommentActions hook handles both so you don't have to.

import {
  TiptopEditor,
  CommentsProvider,
  useComments,
  useCommentActions,
} from 'tiptop-editor'

function MyCommentDrawer() {
  const ctx = useComments()
  const { submit, resolve, remove } = useCommentActions()

  if (!ctx) return null
  const { comments, pendingComment, activeCommentId, setActiveCommentId } = ctx

  return (
    <aside>
      {/* New comment form — shown when the user clicks "Add comment" */}
      {pendingComment && (
        <form onSubmit={e => {
          e.preventDefault()
          const text = new FormData(e.currentTarget).get('comment') as string
          submit(pendingComment, text)
        }}>
          <textarea name="comment" placeholder="Add a comment…" autoFocus />
          <button type="submit">Save</button>
          <button type="button" onClick={() => ctx.setPendingComment(null)}>Cancel</button>
        </form>
      )}

      {/* Comment threads */}
      {comments.map(comment => (
        <div
          key={comment.id}
          data-active={activeCommentId === comment.id || undefined}
          onClick={() => setActiveCommentId(comment.id)}
        >
          <p>{comment.content}</p>
          <button onClick={e => { e.stopPropagation(); resolve(comment.id) }}>Resolve</button>
          <button onClick={e => { e.stopPropagation(); remove(comment.id) }}>Delete</button>
        </div>
      ))}
    </aside>
  )
}

export function ReviewPage() {
  return (
    <CommentsProvider onCommentsChange={comments => saveToApi(comments)}>
      <div style={{ display: 'flex' }}>
        <TiptopEditor editorOptions={{ editable: false, showCommentMenu: true, immediatelyRender: false }} />
        <MyCommentDrawer />
      </div>
    </CommentsProvider>
  )
}

useCommentActions provides three methods:

| Method | Description | |--------|-------------| | submit(pending, content, author?) | Applies the pending comment to the editor (adds the mark/attribute) and registers it in the context | | resolve(commentId) | Removes the mark/attribute from the editor and marks the comment as resolved | | remove(commentId) | Removes the mark/attribute from the editor and deletes the comment from the context |

Notes

  • If you use SSR, keep immediatelyRender: false.
  • The package manages the built-in editor extensions internally. Use editorOptions.extraExtensions to append your own feature extensions.

Feedback

Issues and pull requests are welcome.