Como criar um editor rich text com Claude Code
Implemente um editor rich text em React com Claude Code, Tiptap, toolbar, salvamento JSON/HTML e sanitização segura.
Um editor rich text parece apenas um campo de formulário mais sofisticado. Em produção, ele vira uma decisão de arquitetura: qual formato salvar, como limpar HTML colado, como bloquear XSS, quais protocolos de link aceitar, como limitar imagens, como funcionar no teclado móvel e como renderizar o mesmo conteúdo em uma página pública.
Este guia usa Claude Code de forma controlada. Primeiro escolhemos Tiptap, depois definimos o escopo do prompt, implementamos um componente React/TypeScript com toolbar, salvamos JSON e HTML sanitizado e revisamos os principais riscos. A documentação oficial de Tiptap com React orienta instalar @tiptap/react, @tiptap/pm e @tiptap/starter-kit. O StarterKit já cobre parágrafos, títulos, negrito, itálico e listas.
Por que Tiptap
Usar contenteditable diretamente é tentador, mas não é uma boa base para um produto. A MDN documenta que contenteditable="true" mantém formatação colada, enquanto plaintext-only remove a formatação. Só essa diferença já mostra o quanto você teria de normalizar manualmente: seleção, histórico, colagem, comandos, acessibilidade e comportamento mobile.
Lexical também é uma alternativa forte. O site oficial Lexical o descreve como um framework leve e modular para editores de texto, com pacotes para listas, links e tabelas. Eu escolheria Lexical para nós muito customizados ou entradas de chat. Para CMS, base de conhecimento e descrição de produto, Tiptap costuma chegar mais rápido a uma primeira versão segura.
| Critério | Tiptap | Lexical |
|---|---|---|
| Primeira versão | Rápida com StarterKit | Exige mais desenho de plugins |
| Salvamento | JSON e HTML diretos | Precisa de estratégia de EditorState |
| Extensão | Extensions | Ótimo para custom nodes |
| Uso com Claude Code | Fácil de restringir | Melhor com requisitos muito detalhados |
Instalação e prompt
Instale as dependências antes de pedir a mudança. Não comece com colaboração, comentários, menções ou menu slash. Esses recursos mudam schema, permissões e migrações. A primeira versão deve validar editar, salvar, carregar, visualizar e sanitizar.
npm install @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image @tiptap/extension-character-count dompurify
A documentação de Claude Code explica que a ferramenta lê o codebase, edita arquivos, executa comandos e se integra a ferramentas de desenvolvimento. Por isso, dê um contrato claro:
Crie um editor rich text com Tiptap, React e TypeScript.
Edite apenas src/components/RichTextEditor.tsx.
Suporte bold, italic, H2/H3, listas, links, URL de imagem, saída JSON e HTML sanitizado.
Recuse links e imagens fora de http/https.
Use DOMPurify antes de preview ou salvamento.
Inclua uma checklist manual de testes.
Se o projeto ainda não tem fluxo com Claude Code, comece pelo guia inicial. Se o editor envia dados para formulário ou API, revise validação de formulários. Para Markdown, mantenha a conversão separada e consulte processamento Markdown.
Componente React/TypeScript pronto para copiar
O componente abaixo inclui toolbar, validação de URL, contador, saída JSON, HTML sanitizado e salvamento local. As classes Tailwind são opcionais; troque por CSS comum se necessário.
"use client";
import type { Editor, JSONContent } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import CharacterCount from "@tiptap/extension-character-count";
import DOMPurify from "dompurify";
import type { ReactNode } from "react";
import { useState } from "react";
export type SavedEditorContent = {
json: JSONContent;
html: string;
plainText: string;
};
type Props = {
initialContent?: JSONContent | string;
maxCharacters?: number;
onChange?: (content: SavedEditorContent) => void;
};
const allowedTags = ["p", "br", "strong", "em", "s", "h2", "h3", "ul", "ol", "li", "blockquote", "code", "pre", "a", "img"];
const allowedAttrs = ["href", "src", "alt", "title", "target", "rel"];
function normalizeHttpUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
for (const candidate of [trimmed, `https://${trimmed}`]) {
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") return url.toString();
} catch {
// Try the next candidate.
}
}
return null;
}
export function sanitizeEditorHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
ALLOW_DATA_ATTR: false,
});
}
function buildPayload(editor: Editor): SavedEditorContent {
return {
json: editor.getJSON(),
html: sanitizeEditorHtml(editor.getHTML()),
plainText: editor.getText(),
};
}
export function RichTextEditor({
initialContent = "<p>Start writing...</p>",
maxCharacters = 8000,
onChange,
}: Props) {
const [lastSaved, setLastSaved] = useState<SavedEditorContent | null>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [2, 3] } }),
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: { rel: "noopener noreferrer nofollow", target: "_blank" },
}),
Image.configure({ allowBase64: false }),
CharacterCount.configure({ limit: maxCharacters }),
],
content: initialContent,
immediatelyRender: false,
editorProps: {
attributes: {
class: "min-h-[260px] rounded-b-md border border-t-0 border-slate-300 bg-white p-4 leading-7 outline-none focus:ring-2 focus:ring-sky-500",
"aria-label": "Rich text body",
},
transformPastedHTML(html) {
return sanitizeEditorHtml(html);
},
},
onUpdate({ editor }) {
const payload = buildPayload(editor);
setLastSaved(payload);
onChange?.(payload);
},
});
if (!editor) return null;
const characters = editor.storage.characterCount.characters();
const saveDraft = () => {
const payload = buildPayload(editor);
setLastSaved(payload);
onChange?.(payload);
window.localStorage.setItem("article-draft", JSON.stringify(payload));
};
return (
<section className="rounded-md border border-slate-300 bg-slate-50">
<div className="flex flex-wrap gap-1 border-b bg-white p-2" role="toolbar" aria-label="Formatting toolbar">
<ToolButton active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()}>B</ToolButton>
<ToolButton active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()}>I</ToolButton>
<ToolButton active={editor.isActive("heading", { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</ToolButton>
<ToolButton active={editor.isActive("heading", { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</ToolButton>
<ToolButton active={editor.isActive("bulletList")} onClick={() => editor.chain().focus().toggleBulletList().run()}>List</ToolButton>
<ToolButton active={editor.isActive("orderedList")} onClick={() => editor.chain().focus().toggleOrderedList().run()}>1.</ToolButton>
<ToolButton onClick={() => {
const input = window.prompt("Link URL", "https://");
const href = input ? normalizeHttpUrl(input) : null;
if (!href) return window.alert("Use an http or https URL.");
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
}}>Link</ToolButton>
<ToolButton onClick={() => {
const input = window.prompt("Image URL", "https://");
const src = input ? normalizeHttpUrl(input) : null;
if (!src) return window.alert("Use an http or https image URL.");
editor.chain().focus().setImage({ src, alt: "" }).run();
}}>Image</ToolButton>
</div>
<EditorContent editor={editor} />
<footer className="flex items-center justify-between gap-3 border-t p-3 text-sm">
<span>{characters}/{maxCharacters} characters</span>
<button type="button" onClick={saveDraft} className="rounded bg-slate-900 px-3 py-2 text-white">Save draft</button>
{lastSaved && <span>HTML: {lastSaved.html.length} bytes</span>}
</footer>
</section>
);
}
function ToolButton({ active, onClick, children }: { active?: boolean; onClick: () => void; children: ReactNode }) {
return (
<button type="button" aria-pressed={active} onClick={onClick} className={active ? "rounded border bg-sky-100 px-2 py-1" : "rounded border bg-white px-2 py-1"}>
{children}
</button>
);
}
Salvar e carregar JSON ou HTML
O padrão mais seguro no início é salvar três valores: JSON para reedição, HTML sanitizado para renderização e texto puro para resumo, busca, RSS e limite de caracteres. O guia de saída JSON/HTML do Tiptap lembra que JSON ou HTML podem carregar conteúdo malicioso; validação continua obrigatória.
import { useEffect, useState } from "react";
import { RichTextEditor, type SavedEditorContent } from "./RichTextEditor";
export function ArticleEditorPage() {
const [draft, setDraft] = useState<SavedEditorContent | null>(null);
useEffect(() => {
const raw = window.localStorage.getItem("article-draft");
if (raw) setDraft(JSON.parse(raw) as SavedEditorContent);
}, []);
return (
<main>
<RichTextEditor initialContent={draft?.json ?? "<p>New article</p>"} onChange={setDraft} />
<article dangerouslySetInnerHTML={{ __html: draft?.html ?? "" }} />
</main>
);
}
Segurança e revisão
O repositório oficial DOMPurify descreve a biblioteca como sanitizer XSS para HTML, MathML e SVG. Mesmo assim, repita validações no servidor: usuário, permissão, tamanho do HTML, tamanho do texto, tags permitidas, protocolos de URL e domínios de imagem. O cliente é apenas uma camada.
flowchart LR
A["Editor UI"] --> B["Tiptap JSON"]
A --> C["Sanitized HTML"]
B --> D["Database draft"]
C --> D
D --> E["Public page"]
E --> F["Search / RSS / CTA"]
Peça ao Claude Code uma segunda rodada só de review. Ela deve procurar link javascript:, imagem data:, colagem rica, payload grande, foco de teclado, seleção mobile, botão de salvar, recarga do draft e renderização pública.
Casos de uso
Primeiro: CMS de blog. JSON serve para reedição, HTML para publicação e texto puro para SEO, RSS e busca. Segundo: base de conhecimento interna. Títulos, listas, código e links resolvem muita documentação sem comentários ou aprovação. Terceiro: ecommerce. Rich text melhora a página de produto, mas HTML e imagens precisam de limites. Quarto: edição de rascunhos de IA. HTML gerado por IA também é input não confiável e deve passar pela mesma sanitização.
Armadilhas comuns
Salvar apenas HTML é cômodo, mas dificulta migração, Markdown, sumário e colaboração. Confiar só no sanitizing do cliente é arriscado, porque a API pode receber requisições diretas. Teste mobile não deve ficar para o fim: teclado virtual, seleção, URL longa e imagem costumam quebrar ali. Também evite adicionar tabelas, embeds e menções sem motivo claro de produto.
CTA e resultado testado
Um editor rich text acelera melhoria de conteúdo: artigos SEO, páginas de produto, material de treinamento, documentação e CTAs podem ser atualizados com menos atrito. Para transformar isso em processo, veja treinamento e consultoria Claude Code ou produtos e templates Claude Code.
No teste prático, o melhor resultado veio de definir primeiro o payload: JSON, HTML sanitizado, texto puro e validação de URL. Assim o Claude Code gerou uma mudança menor e mais fácil de revisar. Antes de publicar, teste colagem formatada, link javascript:, texto longo, largura mobile, salvar, recarregar e render público.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Escada de segurança de permissões no Claude Code
Amplie de read-only para edições limitadas, comandos de prova e deploy checks sem perder controle.
Claude Code Small PR Proof Pack: pequenas mudanças fáceis de revisar
Um pacote de prova para PRs do Claude Code: diff, checks, URL pública, CTA e rollback.
Gate de revisão antes do commit com Claude Code
Revisão antes do commit com Claude Code: diff, build, URL pública, Gumroad, consultoria, testes e arquivos fora do escopo.