tiptop-editor
v2.0.0
Published
Notion-like editor built with Tiptap v3 and HeroUI
Maintainers
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
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-editorSetup
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.extraExtensionsAppends custom Tiptap extensions after the built-in set.slotsLets 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:
editorTopeditorBottomselectionMenuPrependselectionMenuAppendtableMenuPrependtableMenuAppenddragHandleDropdown
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,
}}
/>disableDefaultContainerDisables the default HeroUICardwrapper and removes the editor's built-in padding. Use this when you want the editor to live inside your own container/layout.showDragHandleControls whether the block drag handle is rendered. Default:true.extraExtensionsAppends custom Tiptap extensions after the built-in editor set.
Built-in Extensions
The package ships with these extensions enabled out of the box:
StarterKitListKitPlaceholder- custom slash command menu
- custom code block
- custom horizontal rule
TextStyleandColorHighlightTextAlignSubscriptSuperscript- 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/pngimage/jpegimage/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 }, }} />
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"
}
}Notes
- If you use SSR, keep
immediatelyRender: false. - The package manages the built-in editor extensions internally. Use
editorOptions.extraExtensionsto append your own feature extensions.
Feedback
Issues and pull requests are welcome.
