@repo/editor-doc
Rich text document editor built on Tiptap with collaborative editing. WYSIWYG experience, full toolbar, custom extensions. Real-time collaboration, image uploads, markdown support.
Quick Start
Add document editor in 2 minutes:
pnpm add @repo/editor-docWYSIWYG editing, toolbar, extensions included. Skip to Quick Start →
Why @repo/editor-doc?
Tiptap setup duplicated across apps. Toolbar components reimplemented per project. Custom extensions scattered. Image upload handling inconsistent. Collaborative editing requires Y.js configuration. No standardized serialization utilities. Keyboard shortcuts need manual wiring.
@repo/editor-doc solves this with pre-configured Tiptap editor, reusable toolbar components, and collaborative editing support.
Production-ready with StarterKit extensions, full toolbar, image uploads, markdown conversion, collaborative editing, and keyboard shortcuts.
Use cases
- Document editing — Rich text editor for blog posts, documentation, notes
- Collaborative writing — Real-time multi-user editing with Y.js integration
- Content management — WYSIWYG editing for CMS applications
- Markdown editing — Convert between HTML and markdown seamlessly
- Technical documentation — Code blocks, tables, task lists
How it works
@repo/editor-doc provides React components wrapping Tiptap:
import { Editor } from "@repo/editor-doc";
function DocumentPage() {
const [content, setContent] = useState("");
return (
<Editor
content={content}
onChange={setContent}
placeholder="Start writing..."
/>
);
}Uses @tiptap/react for editor core, @tiptap/starter-kit for basic formatting, custom extensions for mentions/images, and Y.js for collaboration.
Key features
WYSIWYG editing — Rich text with bold, italic, headings, lists
Full toolbar — Pre-built toolbar components for all formatting
Custom extensions — Mentions, images, tables, code blocks, task lists
Collaborative editing — Real-time multi-user with Y.js integration
Image uploads — Custom upload handler integration
Markdown support — Convert between HTML and markdown
Quick Start
1. Install the package
pnpm add @repo/editor-doc2. Add editor to your page
"use client";
import { Editor, Toolbar } from "@repo/editor-doc";
import { useState } from "react";
export default function DocumentPage() {
const [content, setContent] = useState("");
return (
<div className="border rounded-lg">
<Toolbar />
<Editor
content={content}
onChange={setContent}
placeholder="Start writing..."
className="min-h-[500px] p-4"
/>
);
}3. Handle image uploads
<Editor
content={content}
onChange={setContent}
onImageUpload={async (file) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const { url } = await response.json();
return url;
}}
/>That's it! You now have a full-featured rich text editor with toolbar and image uploads.
Collaborative editing
Enable real-time collaboration with Y.js:
import { Collaboration } from "@repo/editor-doc/extensions";
<Editor
extensions={[
Collaboration.configure({ document: ydoc }),
]}
collaborationCursor={{
user: { name: "John", color: "#f00" },
}}
/>Technical Details
For Developers: Technical implementation details
Overview
| Property | Value |
|---|---|
| Location | packages/editor-doc |
| Dependencies | @tiptap/react, @tiptap/starter-kit, @tiptap/extension-* |
| Framework | React |
| Bundle Size | ~45KB (with tree-shaking) |
Export Paths
| Path | Description |
|---|---|
@repo/editor-doc | Main editor component |
@repo/editor-doc/extensions | Tiptap extensions |
@repo/editor-doc/components | Editor UI components |
Basic Usage
Simple Editor
import { Editor } from "@repo/editor-doc";
function DocumentPage() {
// highlight-next-line
const [content, setContent] = useState("");
return (
<Editor
content={content}
onChange={setContent}
placeholder="Start writing..."
/>
);
}With Full Toolbar
import { Editor, Toolbar } from "@repo/editor-doc";
function DocumentEditor() {
const [content, setContent] = useState(initialContent);
return (
<div className="border rounded-lg">
{/* highlight-next-line */}
<Toolbar />
<Editor
content={content}
onChange={setContent}
className="min-h-[500px] p-4"
/>
);
}Controlled Content
const [content, setContent] = useState(initialContent);
const handleSave = async () => {
await fetch("/api/documents", {
method: "POST",
body: JSON.stringify({ content }),
});
};
return (
<>
<Editor content={content} onChange={setContent} />
<button onClick={handleSave}>Save Document</button>
</>
);Extensions
Included Extensions
The editor comes pre-configured with:
- StarterKit: Basic formatting (bold, italic, lists, etc.)
- Placeholder: Placeholder text
- Link: Hyperlinks
- Image: Image embedding
- Table: Table support
- CodeBlock: Syntax-highlighted code blocks
- TaskList: Interactive task lists
- Highlight: Text highlighting
- Typography: Smart quotes, dashes
Custom Extensions
import { Editor } from "@repo/editor-doc";
import { Mention } from "@repo/editor-doc/extensions";
<Editor
extensions={[
// highlight-start
Mention.configure({
suggestion: {
items: async ({ query }) => {
return users.filter((u) =>
u.name.toLowerCase().includes(query.toLowerCase())
);
},
render: () => {
let component;
return {
onStart: (props) => {
component = new MentionList(props);
},
onUpdate: (props) => {
component.updateProps(props);
},
onExit: () => {
component.destroy();
},
};
},
},
}),
// highlight-end
]}
/>Emoji Extension
import { Emoji } from "@repo/editor-doc/extensions";
<Editor
extensions={[
Emoji.configure({
enableEmoticons: true,
suggestion: {
items: ({ query }) => {
return emojis.filter((emoji) =>
emoji.name.toLowerCase().includes(query.toLowerCase())
);
},
},
}),
]}
/>Custom Node Extension
import { Node } from "@tiptap/core";
const CalloutExtension = Node.create({
name: "callout",
group: "block",
content: "block+",
defining: true,
addAttributes() {
return {
type: {
default: "info",
parseHTML: (element) => element.getAttribute("data-type"),
renderHTML: (attributes) => ({
"data-type": attributes.type,
}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="callout"]' }];
},
renderHTML({ HTMLAttributes }) {
return ["div", { ...HTMLAttributes, class: "callout" }, 0];
},
});
<Editor extensions={[CalloutExtension]} />Toolbar Components
Pre-built Toolbar
import { Toolbar } from "@repo/editor-doc";
// Default toolbar with all buttons
<Toolbar editor={editor} />
// Minimal toolbar
<Toolbar editor={editor} minimal />
// Custom button set
<Toolbar
editor={editor}
buttons={["bold", "italic", "link", "heading"]}
/>Custom Toolbar
import {
BoldButton,
ItalicButton,
HeadingDropdown,
LinkButton,
ImageButton,
CodeBlockButton,
ListButtons,
AlignmentButtons,
UndoRedoButtons,
ColorPicker,
} from "@repo/editor-doc/components";
function CustomToolbar({ editor }) {
return (
<div className="flex gap-1 p-2 border-b">
{/* Text formatting */}
<BoldButton editor={editor} />
<ItalicButton editor={editor} />
<ColorPicker editor={editor} />
{/* Structure */}
{/* highlight-next-line */}
<HeadingDropdown editor={editor} />
<ListButtons editor={editor} />
<AlignmentButtons editor={editor} />
{/* Content */}
<LinkButton editor={editor} />
<ImageButton editor={editor} />
<CodeBlockButton editor={editor} />
{/* History */}
<UndoRedoButtons editor={editor} />
);
}Individual Toolbar Buttons
// Bold button with custom icon
<BoldButton
editor={editor}
icon={<BoldIcon />}
tooltip="Make text bold (Cmd+B)"
/>
// Heading dropdown with custom options
<HeadingDropdown
editor={editor}
levels={[1, 2, 3]}
labels={{ 1: "Title", 2: "Subtitle", 3: "Section" }}
/>
// Link button with custom dialog
<LinkButton
editor={editor}
onOpenDialog={(currentUrl, onSave) => {
openLinkDialog({ url: currentUrl, onSave });
}}
/>Collaborative Editing
Setup with Y.js
import { Editor } from "@repo/editor-doc";
import { Collaboration, CollaborationCursor } from "@repo/editor-doc/extensions";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(
"wss://your-websocket-server.com",
"document-id",
ydoc
);
<Editor
extensions={[
// highlight-start
Collaboration.configure({
document: ydoc,
}),
CollaborationCursor.configure({
provider,
user: {
name: currentUser.name,
color: currentUser.color,
},
}),
// highlight-end
]}
/>Presence Indicators
import { useCollaborationCursors } from "@repo/editor-doc";
function CollaborationInfo({ editor }) {
const users = useCollaborationCursors(editor);
return (
<div className="flex gap-2">
{users.map((user) => (
<div key={user.id} className="flex items-center gap-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: user.color }}
/>
<span>{user.name}</span>
))}
);
}Conflict Resolution
// Y.js handles conflict resolution automatically
// Last write wins for text changes
// Structural changes are merged intelligently
// Optional: Handle sync events
provider.on("sync", (isSynced) => {
if (isSynced) {
console.log("Document synced with server");
}
});
provider.on("status", ({ status }) => {
console.log("Connection status:", status); // 'connecting', 'connected', 'disconnected'
});Serialization
HTML Output
import { useEditor } from "@repo/editor-doc";
const editor = useEditor({ content });
// highlight-start
// Get HTML
const html = editor.getHTML();
// Set HTML
editor.commands.setContent(htmlString);
// highlight-end
// Get text only (no formatting)
const text = editor.getText();JSON Output
// Get JSON (preserves all metadata)
const json = editor.getJSON();
console.log(json);
// {
// type: 'doc',
// content: [
// { type: 'heading', attrs: { level: 1 }, content: [...] },
// { type: 'paragraph', content: [...] }
// ]
// }
// Set JSON
editor.commands.setContent(jsonContent);Markdown
import { htmlToMarkdown, markdownToHtml } from "@repo/editor-doc";
// highlight-start
// Convert to markdown
const markdown = htmlToMarkdown(editor.getHTML());
console.log(markdown);
// # My Document
// This is **bold** and *italic*
// Convert from markdown
const html = markdownToHtml(markdownContent);
// highlight-end
editor.commands.setContent(html);Custom Serializers
import { generateHTML, generateJSON } from "@tiptap/html";
import { extensions } from "@repo/editor-doc";
// Generate HTML from JSON with custom extensions
const html = generateHTML(jsonContent, extensions);
// Generate JSON from HTML
const json = generateJSON(htmlContent, extensions);Image Handling
Upload Handler
import { Editor } from "@repo/editor-doc";
<Editor
onImageUpload={async (file) => {
// highlight-start
// Upload to your storage
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const { url } = await response.json();
return url;
// highlight-end
}}
/>Image Validation
<Editor
onImageUpload={async (file) => {
// Validate file type
if (!file.type.startsWith("image/")) {
throw new Error("Only images are allowed");
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
throw new Error("Image must be less than 5MB");
}
// Upload and return URL
const url = await uploadImage(file);
return url;
}}
onImageError={(error) => {
toast.error(error.message);
}}
/>Image Attributes
// Set image width/height
editor.commands.setImage({
src: imageUrl,
alt: "Image description",
title: "Image title",
width: 600,
height: 400
});
// Update existing image
editor.commands.updateAttributes("image", {
src: newUrl,
alt: "New description"
});Read-Only Mode
// Static content display
<Editor
content={content}
// highlight-next-line
editable={false}
className="prose"
/>
// Toggle edit mode
const [editable, setEditable] = useState(false);
<>
<button onClick={() => setEditable(!editable)}>
{editable ? "View" : "Edit"}
</button>
<Editor content={content} editable={editable} />
</>Keyboard Shortcuts
Default Shortcuts
| Shortcut | Action |
|---|---|
Ctrl/Cmd + B | Bold |
Ctrl/Cmd + I | Italic |
Ctrl/Cmd + U | Underline |
Ctrl/Cmd + K | Insert link |
Ctrl/Cmd + Shift + 1-6 | Headings |
Ctrl/Cmd + Shift + 8 | Bullet list |
Ctrl/Cmd + Shift + 9 | Numbered list |
Ctrl/Cmd + Z | Undo |
Ctrl/Cmd + Shift + Z | Redo |
Ctrl/Cmd + A | Select all |
Custom Shortcuts
import { Editor } from "@repo/editor-doc";
import { Extension } from "@tiptap/core";
const CustomShortcuts = Extension.create({
addKeyboardShortcuts() {
return {
// Ctrl+S to save
"Mod-s": () => {
this.editor.commands.blur();
handleSave();
return true;
},
// Ctrl+Shift+C to clear formatting
"Mod-Shift-c": () => {
this.editor.commands.clearNodes();
this.editor.commands.unsetAllMarks();
return true;
},
};
},
});
<Editor extensions={[CustomShortcuts]} />Styling
Custom CSS
/* Editor container */
.ProseMirror {
min-height: 300px;
padding: 1rem;
outline: none;
}
/* Placeholder text */
/* highlight-start */
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: #adb5bd;
pointer-events: none;
float: left;
height: 0;
}
/* highlight-end */
/* Selection color */
.ProseMirror ::selection {
background: rgba(0, 123, 255, 0.2);
}
/* Headings */
.ProseMirror h1 {
font-size: 2rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.ProseMirror h2 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.75rem;
}
/* Code blocks */
.ProseMirror pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
overflow-x: auto;
}
/* Tables */
.ProseMirror table {
border-collapse: collapse;
width: 100%;
}
.ProseMirror td,
.ProseMirror th {
border: 1px solid #ddd;
padding: 0.5rem;
}
/* Task lists */
.ProseMirror ul[data-type="taskList"] {
list-style: none;
padding: 0;
}
.ProseMirror ul[data-type="taskList"] li {
display: flex;
align-items: center;
}
.ProseMirror ul[data-type="taskList"] input[type="checkbox"] {
margin-right: 0.5rem;
}Tailwind Styling
<Editor
className="prose prose-sm sm:prose lg:prose-lg xl:prose-xl max-w-none"
content={content}
/>Dark Mode
.dark .ProseMirror {
background: #1a1a1a;
color: #e4e4e4;
}
.dark .ProseMirror pre {
background: #0d0d0d;
}
.dark .ProseMirror td,
.dark .ProseMirror th {
border-color: #333;
}Advanced Features
Content Validation
import { Editor } from "@repo/editor-doc";
<Editor
onUpdate={({ editor }) => {
const json = editor.getJSON();
// Validate content structure
if (hasInvalidContent(json)) {
editor.commands.undo();
toast.error("Invalid content");
}
}}
/>Auto-Save
import { useEffect } from "react";
import { debounce } from "@repo/utils";
function AutoSaveEditor() {
const [content, setContent] = useState(initialContent);
const saveContent = debounce(async (content) => {
await fetch("/api/documents/save", {
method: "POST",
body: JSON.stringify({ content }),
});
toast.success("Auto-saved");
}, 2000);
useEffect(() => {
if (content) {
saveContent(content);
}
}, [content]);
return <Editor content={content} onChange={setContent} />;
}Word Count
import { useWordCount } from "@repo/editor-doc";
function EditorWithWordCount({ editor }) {
const wordCount = useWordCount(editor);
return (
<div>
<Editor editor={editor} />
<div className="text-sm text-gray-500">
{wordCount} words
);
}Character Limit
import { CharacterCount } from "@tiptap/extension-character-count";
<Editor
extensions={[
CharacterCount.configure({
limit: 5000,
}),
]}
onUpdate={({ editor }) => {
const count = editor.storage.characterCount.characters();
if (count > 5000) {
toast.error("Character limit exceeded");
}
}}
/>Testing
Mock Editor in Tests
import { vi } from "vitest";
vi.mock("@repo/editor-doc", () => ({
Editor: ({ content, onChange }) => (
<textarea
value={content}
onChange={(e) => onChange(e.target.value)}
/>
),
Toolbar: () => <div>Toolbar,
}));
describe("DocumentPage", () => {
it("renders editor", () => {
render(<DocumentPage />);
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
});Test Editor Commands
import { Editor } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
describe("Editor", () => {
let editor: Editor;
beforeEach(() => {
editor = new Editor({
extensions: [StarterKit],
content: "<p>Hello</p>"
});
});
it("applies bold formatting", () => {
editor.commands.selectAll();
editor.commands.toggleBold();
expect(editor.getHTML()).toBe("<p><strong>Hello</strong></p>");
});
it("inserts heading", () => {
editor.commands.setHeading({ level: 1 });
expect(editor.getHTML()).toBe("<h1>Hello</h1>");
});
});Troubleshooting
Editor Not Rendering
# 1. Verify installation
pnpm list @repo/editor-doc
# 2. Check React version (18+ required)
pnpm list react
# 3. Clear cache
rm -rf node_modules .next
pnpm installContent Not Updating
// ❌ WRONG - Missing onChange
<Editor content={content} />
// ✅ CORRECT - Controlled component
<Editor content={content} onChange={setContent} />Toolbar Buttons Not Working
// Ensure editor instance is passed
<BoldButton editor={editor} />
// Check if editor is ready
{editor && <Toolbar editor={editor} />}Images Not Uploading
// Verify upload handler returns URL
onImageUpload={async (file) => {
const url = await uploadToStorage(file);
console.log("Uploaded:", url); // Verify URL
return url; // Must return string URL
}}
// Check for errors
onImageError={(error) => {
console.error("Upload failed:", error);
}}Performance Tips
- Lazy load editor for faster initial page load
- Debounce onChange handler for auto-save
- Limit extensions to only what you need
- Use read-only mode for static content display
- Cache serialized content in production
Related Documentation
- @repo/pptx-editor - Presentation editor
- @repo/storage - File uploads
- @repo/ui - UI components