Tips & Tricks (Mis à jour: 02/06/2026)

Créer un éditeur de texte riche avec Claude Code

Implémentez un éditeur rich text React avec Claude Code, Tiptap, barre d'outils, sauvegarde JSON/HTML et sanitisation.

Créer un éditeur de texte riche avec Claude Code

Un éditeur de texte riche n’est pas seulement un champ de saisie plus agréable. En production, il faut gérer le format sauvegardé, le HTML collé depuis d’autres outils, la protection XSS, les liens dangereux, les images externes, la sélection sur mobile et le rendu public. Une démo basée uniquement sur contenteditable peut fonctionner cinq minutes, mais elle devient vite fragile dès qu’un utilisateur colle du contenu réel.

Dans cet article, on utilise Claude Code avec un cadre strict. On choisit Tiptap, on limite les fichiers modifiés, on écrit un composant React/TypeScript copiable, puis on vérifie la sauvegarde JSON/HTML et la sanitisation. La documentation officielle Tiptap React installe @tiptap/react, @tiptap/pm et @tiptap/starter-kit. StarterKit donne déjà paragraphes, titres, gras, italique et listes.

Pourquoi choisir Tiptap

L’attribut contenteditable existe, mais MDN rappelle une différence importante: avec contenteditable="true", le collage conserve la mise en forme; avec plaintext-only, il la retire. Construire un éditeur complet directement sur cette base signifie gérer soi-même les différences de navigateurs, la sélection, l’historique, les commandes, le collage et l’accessibilité.

Lexical est aussi une option solide. Le site officiel Lexical le présente comme un framework léger et modulaire, avec des paquets pour listes, liens et tableaux. Je le choisirais pour des noeuds très personnalisés ou une expérience de saisie type messagerie. Pour un CMS, une base de connaissance ou une fiche produit, Tiptap donne souvent une première version plus lisible et plus facile à cadrer avec Claude Code.

CritèreTiptapLexical
Première versionRapide avec StarterKitPlus de conception plugins/commandes
SauvegardeJSON et HTML facilesStratégie EditorState à définir
ExtensionExtensionsNoeuds et commandes personnalisés
Travail avec Claude CodeFacile à contraindreExcellent avec un cahier des charges précis

Installation et brief Claude Code

Installez les dépendances avant de demander la modification. La première version doit rester volontairement limitée: pas de collaboration temps réel, pas de commentaires, pas de mentions et pas de menu slash. Ces fonctions ajoutent du schéma, des droits et de la migration.

npm install @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image @tiptap/extension-character-count dompurify

La documentation Claude Code explique que l’outil peut lire le codebase, modifier des fichiers, exécuter des commandes et s’intégrer aux outils de développement. Donnez-lui donc un contrat vérifiable:

Crée un éditeur rich text React/TypeScript avec Tiptap.
Ne modifie que src/components/RichTextEditor.tsx.
Fonctions: gras, italique, H2/H3, listes, liens, URL d'image, sortie JSON, sortie HTML sanitisee.
Refuse les liens et images hors http/https.
Utilise DOMPurify avant aperçu ou sauvegarde.
Ajoute une checklist de test manuel.

Pour poser les bases, reliez ce travail au guide de démarrage Claude Code. Si l’éditeur alimente un formulaire, gardez aussi le guide de validation de formulaire. Pour Markdown, séparez la conversion et consultez le traitement Markdown.

Composant React/TypeScript copiable

Ce composant inclut barre d’outils, validation d’URL, compteur, sortie JSON, HTML sanitise et sauvegarde locale. Les classes Tailwind peuvent être remplacées par votre CSS.

"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>
  );
}

Sauvegarder et charger JSON ou HTML

Le meilleur point de départ est de sauvegarder trois sorties: JSON pour rééditer, HTML sanitise pour afficher, texte brut pour extraits, recherche, RSS et limites de longueur. Le guide JSON/HTML de Tiptap rappelle aussi que JSON ou HTML peuvent transporter du contenu malveillant; la validation reste obligatoire.

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>
  );
}

Sécurité, schéma et revue

Le dépôt officiel DOMPurify le décrit comme un sanitizer XSS pour HTML, MathML et SVG. Il faut tout de même répéter les contrôles côté serveur: utilisateur, droits, taille du payload, balises autorisées, protocoles d’URL, domaines d’image et état de publication. Un attaquant peut appeler directement l’API sans passer par votre composant React.

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"]

Demandez à Claude Code une seconde passe de review. Elle doit vérifier les liens javascript:, les images data:, les collages riches, les longs textes, la sélection mobile, le focus clavier, le bouton de sauvegarde et le rendu public. Le but est de suivre la donnée de l’éditeur jusqu’à la page publiée.

Cas d’usage

Premier cas: un CMS de blog. JSON sert à rééditer, HTML sert au rendu public, texte brut sert à la meta description, au RSS et à la recherche. Deuxième cas: une base de connaissance interne. Titres, listes, blocs de code et liens couvrent souvent 80% des besoins. Troisième cas: les fiches produits ecommerce. Le rich text améliore l’argumentaire, mais impose des limites strictes sur HTML et images. Quatrième cas: les brouillons générés par IA. Même si Claude produit le texte, le HTML reste une entrée non fiable et doit être validé.

Pièges fréquents

Ne sauvegardez pas seulement du HTML si le contenu a de la valeur. Cela complique les migrations, le Markdown, les sommaires et la collaboration. Ne faites pas confiance uniquement à la sanitisation client. Ne repoussez pas le test mobile: clavier virtuel, longues URL, sélection et images y cassent souvent. N’ajoutez pas tableaux, embeds, mentions ou collaboration sans raison produit claire.

CTA et résultat testé

Un éditeur rich text accélère les boucles de revenu: articles SEO, pages produit, supports de formation, documentation et CTA peuvent être corrigés plus vite. Pour structurer ce flux, consultez la formation et consultation Claude Code ou les produits et templates Claude Code.

En testant ce flux, le meilleur résultat vient du fait de décider d’abord le payload: JSON, HTML sanitise, texte brut et validation d’URL. Claude Code produit alors une diff plus petite et plus facile à relire. Avant publication, vérifiez collage riche, lien javascript:, texte long, largeur mobile, sauvegarde, recharge et rendu public.

#Claude Code #éditeur rich text #React #Tiptap #gestion de contenu
Gratuit

PDF gratuit: cheatsheet Claude Code

Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.

Nous protégeons vos données et n'envoyons pas de spam.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.