Skip to content

Rich Text Editor (TipTap) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace plain textarea inputs with TipTap rich text editor for post creation and comments.

Architecture: TipTap editor component wraps ProseMirror. Content serialized as ProseMirror JSON (tiptap_body) for the backend. Fallback body (HTML) also sent for compatibility.

Tech Stack: @tiptap/react, @tiptap/starter-kit, @tiptap/extension-placeholder, @tiptap/extension-mention, @tiptap/extension-image, @tiptap/extension-link, @tiptap/extension-task-list, @tiptap/extension-task-item


File Structure

src/
├── components/
│   └── editor/
│       ├── TipTapEditor.tsx          # Reusable editor component
│       ├── EditorToolbar.tsx         # Formatting toolbar
│       └── extensions.ts             # Custom extensions config
├── lib/
│   └── editor.ts                     # Serialization helpers (JSON ↔ HTML)
├── routes/
│   ├── spaces/$spaceId.tsx           # Modify: post composer uses TipTap
│   └── spaces/$spaceId/p/$postId.tsx # Modify: comment form uses TipTap
└── components/circle/
    └── PostCard.tsx                   # Modify: comment input uses TipTap

Task 1: Install TipTap Dependencies

Files:

  • Modify: package.json

  • [ ] Step 1: Install TipTap packages

bash
cd /home/juninho/dev/go/django-boilerplate/frontend && bun add @tiptap/react @tiptap/starter-kit @tiptap/pm @tiptap/extension-placeholder @tiptap/extension-mention @tiptap/extension-image @tiptap/extension-link @tiptap/extension-task-list @tiptap/extension-task-item @tiptap/extension-underline @tiptap/extension-text-align @tiptap/extension-highlight
  • [ ] Step 2: Commit
bash
git add package.json bun.lock
git commit -m "feat: install TipTap editor dependencies"

Task 2: Editor Serialization Helpers

Files:

  • Create: src/lib/editor.ts

  • [ ] Step 1: Create editor utilities

typescript
// src/lib/editor.ts

import type { JSONContent } from "@tiptap/react";

export function jsonToHtml(json: JSONContent): string {
  if (!json || !json.content) return "";
  return renderNode(json);
}

function renderNode(node: JSONContent): string {
  if (node.type === "text" && node.text !== undefined) {
    let text = escapeHtml(node.text);
    if (node.marks) {
      for (const mark of node.marks) {
        text = applyMark(text, mark);
      }
    }
    return text;
  }

  const children = (node.content ?? []).map(renderNode).join("");

  switch (node.type) {
    case "doc":
      return children;
    case "paragraph":
      return `<p>${children}</p>`;
    case "heading": {
      const level = node.attrs?.level ?? 1;
      return `<h${level}>${children}</h${level}>`;
    }
    case "blockquote":
      return `<blockquote>${children}</blockquote>`;
    case "bulletList":
      return `<ul>${children}</ul>`;
    case "orderedList":
      return `<ol>${children}</ol>`;
    case "listItem":
      return `<li>${children}</li>`;
    case "codeBlock":
      return `<pre><code>${children}</code></pre>`;
    case "hardBreak":
      return "<br />";
    case "horizontalRule":
      return "<hr />";
    case "image": {
      const src = node.attrs?.src ?? "";
      const alt = node.attrs?.alt ?? "";
      return `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" />`;
    }
    case "taskList":
      return `<ul class="task-list">${children}</ul>`;
    case "taskItem": {
      const checked = node.attrs?.checked ? " checked" : "";
      return `<li class="task-item"${checked}>${children}</li>`;
    }
    default:
      return children;
  }
}

function applyMark(text: string, mark: { type: string; attrs?: Record<string, unknown> }): string {
  switch (mark.type) {
    case "bold":
      return `<strong>${text}</strong>`;
    case "italic":
      return `<em>${text}</em>`;
    case "underline":
      return `<u>${text}</u>`;
    case "strike":
      return `<s>${text}</s>`;
    case "code":
      return `<code>${text}</code>`;
    case "link": {
      const href = mark.attrs?.href ?? "#";
      const target = mark.attrs?.target === "_blank" ? ' target="_blank" rel="noopener"' : "";
      return `<a href="${escapeHtml(String(href))}"${target}>${text}</a>`;
    }
    case "highlight":
      return `<mark>${text}</mark>`;
    default:
      return text;
  }
}

function escapeHtml(str: string): string {
  return str
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

export function emptyDoc(): JSONContent {
  return {
    type: "doc",
    content: [{ type: "paragraph" }],
  };
}

export function isEmptyDoc(json: JSONContent): boolean {
  if (!json || !json.content) return true;
  if (json.content.length === 0) return true;
  if (json.content.length === 1) {
    const node = json.content[0];
    if (node.type === "paragraph" && (!node.content || node.content.length === 0)) return true;
  }
  return false;
}
  • [ ] Step 2: Commit
bash
git add src/lib/editor.ts
git commit -m "feat: add TipTap editor serialization helpers"

Task 3: TipTap Editor Component

Files:

  • Create: src/components/editor/TipTapEditor.tsx

  • Create: src/components/editor/EditorToolbar.tsx

  • [ ] Step 1: Create EditorToolbar

typescript
// src/components/editor/EditorToolbar.tsx

import {
  Bold, Italic, Underline as UnderlineIcon, Strikethrough,
  Code, Heading1, Heading2, Heading3,
  List, ListOrdered, Quote, Code2,
  AlignLeft, AlignCenter, AlignRight,
  ImagePlus, Link as LinkIcon, Highlighter,
  ListChecks, Minus,
} from "lucide-react";
import type { Editor } from "@tiptap/react";

function ToolbarBtn({
  onClick,
  active = false,
  disabled = false,
  children,
  title,
}: {
  onClick: () => void;
  active?: boolean;
  disabled?: boolean;
  children: React.ReactNode;
  title: string;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      title={title}
      className={`h-8 w-8 grid place-items-center rounded-md text-sm transition ${
        active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent hover:text-foreground"
      } ${disabled ? "opacity-40 cursor-not-allowed" : ""}`}
    >
      {children}
    </button>
  );
}

function Divider() {
  return <div className="h-5 w-px bg-border mx-0.5" />;
}

export function EditorToolbar({ editor }: { editor: Editor }) {
  const setLink = () => {
    const url = window.prompt("URL do link:");
    if (url === null) return;
    if (url === "") {
      editor.chain().focus().extendMarkRange("link").unsetLink().run();
      return;
    }
    editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
  };

  const addImage = () => {
    const url = window.prompt("URL da imagem:");
    if (url) {
      editor.chain().focus().setImage({ src: url }).run();
    }
  };

  return (
    <div className="flex items-center gap-0.5 flex-wrap border-b border-border px-2 py-1">
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleBold().run()}
        active={editor.isActive("bold")}
        title="Negrito"
      >
        <Bold className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleItalic().run()}
        active={editor.isActive("italic")}
        title="Itálico"
      >
        <Italic className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleUnderline().run()}
        active={editor.isActive("underline")}
        title="Sublinhado"
      >
        <UnderlineIcon className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleStrike().run()}
        active={editor.isActive("strike")}
        title="Tachado"
      >
        <Strikethrough className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleCode().run()}
        active={editor.isActive("code")}
        title="Código inline"
      >
        <Code className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleHighlight().run()}
        active={editor.isActive("highlight")}
        title="Destaque"
      >
        <Highlighter className="h-4 w-4" />
      </ToolbarBtn>

      <Divider />

      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
        active={editor.isActive("heading", { level: 1 })}
        title="Título 1"
      >
        <Heading1 className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
        active={editor.isActive("heading", { level: 2 })}
        title="Título 2"
      >
        <Heading2 className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
        active={editor.isActive("heading", { level: 3 })}
        title="Título 3"
      >
        <Heading3 className="h-4 w-4" />
      </ToolbarBtn>

      <Divider />

      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleBulletList().run()}
        active={editor.isActive("bulletList")}
        title="Lista"
      >
        <List className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleOrderedList().run()}
        active={editor.isActive("orderedList")}
        title="Lista numerada"
      >
        <ListOrdered className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleTaskList().run()}
        active={editor.isActive("taskList")}
        title="Lista de tarefas"
      >
        <ListChecks className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleBlockquote().run()}
        active={editor.isActive("blockquote")}
        title="Citação"
      >
        <Quote className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().toggleCodeBlock().run()}
        active={ editor.isActive("codeBlock")}
        title="Bloco de código"
      >
        <Code2 className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().setHorizontalRule().run()}
        title="Linha horizontal"
      >
        <Minus className="h-4 w-4" />
      </ToolbarBtn>

      <Divider />

      <ToolbarBtn
        onClick={() => editor.chain().focus().setTextAlign("left").run()}
        active={editor.isActive({ textAlign: "left" })}
        title="Alinhar à esquerda"
      >
        <AlignLeft className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().setTextAlign("center").run()}
        active={editor.isActive({ textAlign: "center" })}
        title="Centralizar"
      >
        <AlignCenter className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn
        onClick={() => editor.chain().focus().setTextAlign("right").run()}
        active={editor.isActive({ textAlign: "right" })}
        title="Alinhar à direita"
      >
        <AlignRight className="h-4 w-4" />
      </ToolbarBtn>

      <Divider />

      <ToolbarBtn onClick={setLink} active={editor.isActive("link")} title="Link">
        <LinkIcon className="h-4 w-4" />
      </ToolbarBtn>
      <ToolbarBtn onClick={addImage} title="Imagem">
        <ImagePlus className="h-4 w-4" />
      </ToolbarBtn>
    </div>
  );
}
  • [ ] Step 2: Create TipTapEditor
typescript
// src/components/editor/TipTapEditor.tsx

import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Underline from "@tiptap/extension-underline";
import TextAlign from "@tiptap/extension-text-align";
import Highlight from "@tiptap/extension-highlight";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import { useEffect } from "react";
import { EditorToolbar } from "./EditorToolbar";
import type { JSONContent } from "@tiptap/react";

type TipTapEditorProps = {
  content: JSONContent;
  onChange: (json: JSONContent) => void;
  placeholder?: string;
  editable?: boolean;
  minimal?: boolean;
  className?: string;
};

export function TipTapEditor({
  content,
  onChange,
  placeholder = "Escreva algo…",
  editable = true,
  minimal = false,
  className = "",
}: TipTapEditorProps) {
  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        heading: minimal ? false : { levels: [1, 2, 3] },
      }),
      Placeholder.configure({ placeholder }),
      Underline,
      TextAlign.configure({ types: ["heading", "paragraph"] }),
      Highlight,
      TaskList,
      TaskItem.configure({ nested: true }),
      Link.configure({
        openOnClick: false,
        HTMLAttributes: { class: "text-primary underline" },
      }),
      Image.configure({ inline: true }),
    ],
    content,
    editable,
    onUpdate: ({ editor: e }) => {
      onChange(e.getJSON());
    },
    editorProps: {
      attributes: {
        class: "prose prose-sm max-w-none focus:outline-none min-h-[80px] px-4 py-3 text-sm",
      },
    },
  });

  useEffect(() => {
    if (editor && JSON.stringify(editor.getJSON()) !== JSON.stringify(content)) {
      editor.commands.setContent(content);
    }
  }, [content]);

  if (!editor) return null;

  return (
    <div className={`rounded-lg border border-border bg-background overflow-hidden ${className}`}>
      {editable && <EditorToolbar editor={editor} />}
      <EditorContent editor={editor} />
    </div>
  );
}
  • [ ] Step 3: Commit
bash
git add src/components/editor/
git commit -m "feat: add TipTap editor component with toolbar"

Task 4: Update Post Composer in Space Page

Files:

  • Modify: src/routes/spaces/$spaceId.tsx

  • [ ] Step 1: Update post composer to use TipTap

In src/routes/spaces/$spaceId.tsx:

  1. Add imports:
typescript
import { TipTapEditor } from "@/components/editor/TipTapEditor";
import { jsonToHtml, emptyDoc, isEmptyDoc } from "@/lib/editor";
import type { JSONContent } from "@tiptap/react";
  1. Add state for rich text (after existing postTitle/postBody state):
typescript
const [postRichBody, setPostRichBody] = useState<JSONContent>(emptyDoc());
  1. Update onSubmitPost to send tiptap_body:
typescript
const onSubmitPost = (e: React.FormEvent) => {
  e.preventDefault();
  if (isEmptyDoc(postRichBody)) return;
  createPost.mutate(
    {
      space_id: spaceId,
      title: postTitle.trim() || undefined,
      tiptap_body: { body: postRichBody },
      body_plain_text: jsonToHtml(postRichBody).replace(/<[^>]*>/g, "").trim(),
    },
    {
      onSuccess: () => {
        setPostTitle("");
        setPostRichBody(emptyDoc());
        setComposerOpen(false);
      },
    },
  );
};
  1. In the PostsView component, replace the textarea with TipTapEditor:
tsx
<TipTapEditor
  content={postRichBody}
  onChange={setPostRichBody}
  placeholder="No que você está pensando?"
  minimal
/>
  1. Update the submit button disabled condition:
tsx
disabled={createPost.isPending || isEmptyDoc(postRichBody)}
  1. Add the new props to PostsView and SpaceBody type definitions.
  • [ ] Step 2: Commit
bash
git add src/routes/spaces/\$spaceId.tsx
git commit -m "feat: replace post composer textarea with TipTap editor"

Task 5: Update Comment Form in Post Detail

Files:

  • Modify: src/routes/spaces/$spaceId/p/$postId.tsx

  • [ ] Step 1: Update comment form to use TipTap

In src/routes/spaces/$spaceId/p/$postId.tsx:

  1. Add imports:
typescript
import { TipTapEditor } from "@/components/editor/TipTapEditor";
import { jsonToHtml, emptyDoc, isEmptyDoc } from "@/lib/editor";
import type { JSONContent } from "@tiptap/react";
  1. Replace the comment draft state:
typescript
// Replace: const [draft, setDraft] = useState("");
const [commentBody, setCommentBody] = useState<JSONContent>(emptyDoc());
  1. Update onSubmitComment:
typescript
const onSubmitComment = (e: React.FormEvent) => {
  e.preventDefault();
  if (isEmptyDoc(commentBody)) return;
  addComment.mutate(
    { postId: numericPostId, text: jsonToHtml(commentBody) },
    { onSuccess: () => setCommentBody(emptyDoc()) },
  );
};
  1. Replace the comment input with TipTapEditor:
tsx
<TipTapEditor
  content={commentBody}
  onChange={setCommentBody}
  placeholder="Escreva um comentário…"
  minimal
  className="flex-1"
/>
  1. Update the submit button disabled condition:
tsx
disabled={addComment.isPending || isEmptyDoc(commentBody)}
  • [ ] Step 2: Commit
bash
git add src/routes/spaces/\$spaceId/p/\$postId.tsx
git commit -m "feat: replace comment textarea with TipTap editor in post detail"

Task 6: Update Comment Input in PostCard

Files:

  • Modify: src/components/circle/PostCard.tsx

  • [ ] Step 1: Update PostCard comment input to use TipTap

In src/components/circle/PostCard.tsx:

  1. Add imports:
typescript
import { TipTapEditor } from "@/components/editor/TipTapEditor";
import { jsonToHtml, emptyDoc, isEmptyDoc } from "@/lib/editor";
import type { JSONContent } from "@tiptap/react";
  1. Replace the draft state:
typescript
// Replace: const [draft, setDraft] = useState("");
const [commentBody, setCommentBody] = useState<JSONContent>(emptyDoc());
  1. Update submitComment:
typescript
const submitComment = (e: React.FormEvent) => {
  e.preventDefault();
  if (isEmptyDoc(commentBody)) return;
  commentMutation.mutate(
    { postId: post.id, text: jsonToHtml(commentBody) },
    {
      onSuccess: (created) => {
        setComments((c) => [
          ...c,
          {
            author: created.author.name,
            text: created.body_text,
            from: "from-orange-400",
            to: "to-pink-500",
          },
        ]);
      },
    },
  );
  setCommentBody(emptyDoc());
  setShowComments(true);
};
  1. Replace the comment input at the bottom with TipTapEditor:
tsx
<form onSubmit={submitComment} className="border-t border-border px-3 sm:px-5 py-2.5">
  <div className="flex items-start gap-2">
    <div className="h-8 w-8 rounded-full bg-gradient-to-br from-orange-400 to-pink-500 shrink-0" />
    <div className="flex-1 flex flex-col gap-2">
      <TipTapEditor
        content={commentBody}
        onChange={setCommentBody}
        placeholder="Write a comment…"
        minimal
      />
      <div className="flex justify-end">
        <button
          type="submit"
          disabled={commentMutation.isPending || isEmptyDoc(commentBody)}
          className="px-4 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium disabled:opacity-40 transition"
        >
          {commentMutation.isPending ? "Enviando…" : "Enviar"}
        </button>
      </div>
    </div>
  </div>
</form>
  • [ ] Step 2: Commit
bash
git add src/components/circle/PostCard.tsx
git commit -m "feat: replace PostCard comment input with TipTap editor"

Task 7: Verify Build

Files: None (verification only)

  • [ ] Step 1: Run typecheck
bash
cd /home/juninho/dev/go/django-boilerplate/frontend && npx tsc --noEmit
  • [ ] Step 2: Run build
bash
cd /home/juninho/dev/go/django-boilerplate/frontend && npm run build
  • [ ] Step 3: Fix any issues and commit
bash
git add -A && git commit -m "fix: resolve typecheck issues in TipTap editor"

Strum — Documentação.