OneApp Docs
PackagesEditors

@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-doc

WYSIWYG 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-doc

2. Add editor to your page

app/document/page.tsx
"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

app/document/page.tsx
<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

PropertyValue
Locationpackages/editor-doc
Dependencies@tiptap/react, @tiptap/starter-kit, @tiptap/extension-*
FrameworkReact
Bundle Size~45KB (with tree-shaking)

Export Paths

PathDescription
@repo/editor-docMain editor component
@repo/editor-doc/extensionsTiptap extensions
@repo/editor-doc/componentsEditor 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

ShortcutAction
Ctrl/Cmd + BBold
Ctrl/Cmd + IItalic
Ctrl/Cmd + UUnderline
Ctrl/Cmd + KInsert link
Ctrl/Cmd + Shift + 1-6Headings
Ctrl/Cmd + Shift + 8Bullet list
Ctrl/Cmd + Shift + 9Numbered list
Ctrl/Cmd + ZUndo
Ctrl/Cmd + Shift + ZRedo
Ctrl/Cmd + ASelect 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 install

Content 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

External Resources

On this page