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 TipTapTask 1: Install TipTap Dependencies
Files:
Modify:
package.json[ ] Step 1: Install TipTap packages
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
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
// 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
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
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.tsxCreate:
src/components/editor/EditorToolbar.tsx[ ] Step 1: Create EditorToolbar
// 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
// 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
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:
- Add imports:
import { TipTapEditor } from "@/components/editor/TipTapEditor";
import { jsonToHtml, emptyDoc, isEmptyDoc } from "@/lib/editor";
import type { JSONContent } from "@tiptap/react";- Add state for rich text (after existing postTitle/postBody state):
const [postRichBody, setPostRichBody] = useState<JSONContent>(emptyDoc());- Update
onSubmitPostto send tiptap_body:
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);
},
},
);
};- In the
PostsViewcomponent, replace the textarea with TipTapEditor:
<TipTapEditor
content={postRichBody}
onChange={setPostRichBody}
placeholder="No que você está pensando?"
minimal
/>- Update the submit button disabled condition:
disabled={createPost.isPending || isEmptyDoc(postRichBody)}- Add the new props to
PostsViewandSpaceBodytype definitions.
- [ ] Step 2: Commit
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:
- Add imports:
import { TipTapEditor } from "@/components/editor/TipTapEditor";
import { jsonToHtml, emptyDoc, isEmptyDoc } from "@/lib/editor";
import type { JSONContent } from "@tiptap/react";- Replace the comment draft state:
// Replace: const [draft, setDraft] = useState("");
const [commentBody, setCommentBody] = useState<JSONContent>(emptyDoc());- Update
onSubmitComment:
const onSubmitComment = (e: React.FormEvent) => {
e.preventDefault();
if (isEmptyDoc(commentBody)) return;
addComment.mutate(
{ postId: numericPostId, text: jsonToHtml(commentBody) },
{ onSuccess: () => setCommentBody(emptyDoc()) },
);
};- Replace the comment input with TipTapEditor:
<TipTapEditor
content={commentBody}
onChange={setCommentBody}
placeholder="Escreva um comentário…"
minimal
className="flex-1"
/>- Update the submit button disabled condition:
disabled={addComment.isPending || isEmptyDoc(commentBody)}- [ ] Step 2: Commit
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:
- Add imports:
import { TipTapEditor } from "@/components/editor/TipTapEditor";
import { jsonToHtml, emptyDoc, isEmptyDoc } from "@/lib/editor";
import type { JSONContent } from "@tiptap/react";- Replace the draft state:
// Replace: const [draft, setDraft] = useState("");
const [commentBody, setCommentBody] = useState<JSONContent>(emptyDoc());- Update
submitComment:
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);
};- Replace the comment input at the bottom with TipTapEditor:
<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
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
cd /home/juninho/dev/go/django-boilerplate/frontend && npx tsc --noEmit- [ ] Step 2: Run build
cd /home/juninho/dev/go/django-boilerplate/frontend && npm run build- [ ] Step 3: Fix any issues and commit
git add -A && git commit -m "fix: resolve typecheck issues in TipTap editor"