Aller au contenu

L'éditeur de texte riche, fourni

Temps 1 · séance 4 · ~50 min

Les slides du chapitre (PDF)PDF

  • Installer et brancher un éditeur de texte riche fourni dans un <Form> d'Inertia.
  • Expliquer ce que l'éditeur envoie au serveur, et comment il passe par un champ caché.
  • Afficher le contenu d'un projet avec v-html et les styles de prose.
  • Dire pourquoi v-html convient à votre portfolio, et quand il ne convient plus.

Le code qui sent mauvais

En binôme, cinq minutes. Cette ligne de la fiche d'un projet, écrite au chapitre 1.07, affiche le HTML de la colonne content tel qu'il est en base :

<div v-html="project.content"></div>

Aujourd'hui, un éditeur va remplir cette colonne depuis le back-office. Qui peut écrire dans content ? Et si la même ligne affichait des commentaires laissés par les visiteurs, que pourrait y glisser un visiteur malveillant ? Indice : un attribut HTML comme onclick contient du JavaScript.

1. Pourquoi un éditeur, et pas un textarea

Au chapitre 1.06, vous avez écrit le contenu de vos projets en HTML, dans une chaîne PHP, avec une apostrophe échappée par \' dans chaque d'interfaces. La fiche l'affiche en paragraphes grâce à v-html.

Depuis le back-office, un textarea ne suffit pas. Il ne produit que du texte brut. Pour un titre ou un mot en gras, il faudrait taper les balises à la main, <h2> et <strong>, et la moindre balise oubliée casse la mise en page de la fiche.

Un éditeur de texte riche est une zone de saisie qui met en forme pendant que vous écrivez, comme un traitement de texte. Vous cliquez sur « Gras », le mot apparaît en gras, et l'éditeur écrit lui-même la balise <strong>. Le portfolio utilise Tiptap, une bibliothèque d'éditeur pour Vue.

Le composant est fourni. Vous ne l'écrivez pas : vous l'installez, vous le branchez dans le formulaire, et vous comprenez ce qu'il envoie.

2. Installer les paquets

Arrêtez composer run dev, puis installez Tiptap et le plugin de typographie de Tailwind :

npm install @tiptap/vue-3 @tiptap/starter-kit @tiptap/pm
npm install -D @tailwindcss/typography

@tiptap/vue-3 fournit l'éditeur pour Vue. @tiptap/starter-kit regroupe les fonctions courantes : paragraphes, titres, gras, listes, liens, blocs de code. @tiptap/pm est le moteur d'édition sur lequel Tiptap repose. @tailwindcss/typography sert à l'affichage, à la section 6. L'option -D le range dans les devDependencies de package.json, les paquets qui ne servent qu'à préparer le site.

Attention

Après l'installation, npm signale 2 moderate severity vulnerabilities dans une dépendance de @tailwindcss/typography, et propose npm audit fix --force. Ne lancez pas cette commande. Elle remplacerait le plugin par une ancienne version, la 0.5.4, que npm présente lui-même comme « a breaking change ».

Relancez composer run dev.

3. Le composant fourni

Créez resources/js/components/RichTextEditor.vue et copiez-y ce fichier en entier :

<script setup>
import {
    Bold,
    Code,
    Heading2,
    Heading3,
    Italic,
    Link,
    List,
    ListOrdered,
} from '@lucide/vue';
import StarterKit from '@tiptap/starter-kit';
import { EditorContent, useEditor } from '@tiptap/vue-3';
import { onBeforeUnmount, ref } from 'vue';
import { Button } from '@/components/ui/button';

const props = defineProps({
    name: { type: String, required: true },
    defaultValue: { type: String, default: '' },
    label: { type: String, default: 'Contenu' },
});

// Le HTML de l'éditeur, recopié dans le champ caché que <Form> envoie.
const html = ref(props.defaultValue ?? '');

const editor = useEditor({
    content: props.defaultValue ?? '',
    extensions: [
        StarterKit.configure({
            heading: { levels: [2, 3] },
            strike: false,
            underline: false,
            horizontalRule: false,
            trailingNode: false,
            link: { openOnClick: false, defaultProtocol: 'https' },
        }),
    ],
    editorProps: {
        attributes: {
            role: 'textbox',
            'aria-multiline': 'true',
            'aria-label': props.label,
            class: 'prose prose-sm max-w-none min-h-48 px-3 py-2 focus:outline-none dark:prose-invert',
        },
    },
    onUpdate: ({ editor }) => {
        html.value = editor.isEmpty ? '' : editor.getHTML();
    },
});

onBeforeUnmount(() => editor.value?.destroy());

function setLink() {
    const previous = editor.value.getAttributes('link').href ?? '';
    const url = window.prompt('Adresse du lien', previous);

    if (url === null) {
        return;
    }

    if (url === '') {
        editor.value.chain().focus().unsetLink().run();

        return;
    }

    editor.value.chain().focus().setLink({ href: url }).run();
}

const buttons = [
    {
        label: 'Titre 2',
        icon: Heading2,
        active: () => editor.value.isActive('heading', { level: 2 }),
        run: () =>
            editor.value.chain().focus().toggleHeading({ level: 2 }).run(),
    },
    {
        label: 'Titre 3',
        icon: Heading3,
        active: () => editor.value.isActive('heading', { level: 3 }),
        run: () =>
            editor.value.chain().focus().toggleHeading({ level: 3 }).run(),
    },
    {
        label: 'Gras',
        icon: Bold,
        active: () => editor.value.isActive('bold'),
        run: () => editor.value.chain().focus().toggleBold().run(),
    },
    {
        label: 'Italique',
        icon: Italic,
        active: () => editor.value.isActive('italic'),
        run: () => editor.value.chain().focus().toggleItalic().run(),
    },
    {
        label: 'Liste à puces',
        icon: List,
        active: () => editor.value.isActive('bulletList'),
        run: () => editor.value.chain().focus().toggleBulletList().run(),
    },
    {
        label: 'Liste numérotée',
        icon: ListOrdered,
        active: () => editor.value.isActive('orderedList'),
        run: () => editor.value.chain().focus().toggleOrderedList().run(),
    },
    {
        label: 'Lien',
        icon: Link,
        active: () => editor.value.isActive('link'),
        run: setLink,
    },
    {
        label: 'Bloc de code',
        icon: Code,
        active: () => editor.value.isActive('codeBlock'),
        run: () => editor.value.chain().focus().toggleCodeBlock().run(),
    },
];
</script>

<template>
    <div
        class="rounded-md border border-input shadow-xs focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50"
    >
        <div
            v-if="editor"
            class="flex flex-wrap gap-1 border-b border-input p-1"
        >
            <Button
                v-for="button in buttons"
                :key="button.label"
                type="button"
                variant="ghost"
                size="icon-sm"
                :title="button.label"
                :aria-label="button.label"
                :aria-pressed="button.active()"
                :class="{ 'bg-accent': button.active() }"
                @click="button.run"
            >
                <component :is="button.icon" />
            </Button>
        </div>

        <EditorContent :editor="editor" />

        <input type="hidden" :name="name" :value="html" />
    </div>
</template>

L'éditeur dans le formulaire de modification : une barre de huit icônes (titre 2, titre 3, gras, italique, liste à puces, liste numérotée, lien, bloc de code) au-dessus d'un texte avec un titre, un paragraphe dont un mot en gras et un lien, et un bloc de code

Vous n'avez pas à tout comprendre pour l'utiliser. Lisez au moins ces quatre passages.

  • Les props. name est le nom du champ envoyé au serveur, defaultValue le HTML de départ, et label le nom que lit un lecteur d'écran.
  • useEditor() crée l'éditeur. StarterKit.configure() garde les titres de niveaux 2 et 3, et désactive le barré, le souligné et la ligne horizontale. La barre n'a pas de bouton pour eux, et un raccourci comme Ctrl+U (Cmd+U sur Mac) produirait sinon une balise que vous ne verriez nulle part.
  • onUpdate s'exécute à chaque frappe. editor.getHTML() renvoie le contenu en HTML, et la ligne le recopie dans la variable html. Un éditeur vide renverrait <p></p> : editor.isEmpty remplace ce paragraphe vide par une chaîne vide.
  • La barre est une liste de huit boutons, rendue par un v-for. Chaque bouton porte type="button". Le Button de shadcn-vue rend une balise <button> sans type, et un bouton sans type, dans un formulaire, envoie le formulaire. Sans cet attribut, un clic sur « Gras » enregistrerait le projet.

Le bouton « Lien » ouvre la petite fenêtre du navigateur « Adresse du lien ». Une adresse vide retire le lien. Le bouton « Bloc de code » produit <pre><code>, pour citer quelques lignes de votre projet.

4. Le brancher dans le <Form>

Au chapitre 1.12, vous avez vu que <Form> est non contrôlé : il ne suit pas les champs pendant la saisie. Au moment de l'envoi, il lit tous les champs du formulaire qui portent un attribut name, comme le ferait un formulaire HTML classique.

La zone de l'éditeur n'est pas un champ de formulaire. C'est une div modifiable, que <Form> ignore. Le composant contourne le problème avec sa dernière ligne :

<input type="hidden" :name="name" :value="html" />

Ce champ caché porte le nom content et reçoit le HTML à chaque frappe. <Form> le lit comme n'importe quel autre champ. Vous n'écrivez ni v-model, ni fonction d'envoi.

Dans resources/js/pages/admin/projects/Create.vue, importez le composant avec les autres :

import RichTextEditor from '@/components/RichTextEditor.vue';

Ajoutez ensuite ce bloc entre le résumé et le lien de la démo :

<div class="grid gap-2">
    <Label>Contenu</Label>
    <RichTextEditor name="content" label="Contenu du projet" />
    <InputError :message="errors.content" />
</div>

Le <Label> n'a pas d'attribut for : une div modifiable ne peut pas être visée par une étiquette. Le composant pose role="textbox" et aria-label sur la zone pour qu'un lecteur d'écran l'annonce quand même.

Dans Edit.vue, ajoutez le même bloc, avec le contenu actuel du projet :

<RichTextEditor
    name="content"
    label="Contenu du projet"
    :default-value="project.content"
/>

content fait partie des props de la page de modification depuis le chapitre 1.13, si vous avez suivi son critère ; sinon, ajoutez 'content' à l'only() de edit(), sans quoi l'éditeur s'ouvre vide et enregistrer efface le contenu. La règle de validation existe aussi : 'content' => ['nullable', 'string'] depuis le chapitre 1.12.

5. Ce que l'éditeur envoie

Ouvrez le formulaire de création. Dans l'éditeur, cliquez sur « Titre 2 » et tapez « Le projet ». Passez à la ligne, tapez un texte, et mettez un mot en gras avec Ctrl+B (Cmd+B sur Mac). Laissez le titre et le résumé vides, et envoyez. Voici le corps de la requête relevé dans le navigateur :

POST http://…/admin/projets
{"title":"","summary":"","content":"<h2>Le projet</h2><p>Un texte <strong>important</strong>.</p>…","demo_url":"","repo_url":""}

L'éditeur envoie du HTML, dans la clé content, à côté des autres champs. Les balises sont celles de la barre : <h2> pour le titre, <p> pour le paragraphe, <strong> pour le gras.

Les messages « Le champ titre est obligatoire. » et « Le champ résumé est obligatoire. » s'affichent, et le texte de l'éditeur reste en place. La page n'a pas été rechargée, comme au chapitre 1.12.

Ouvrez maintenant la modification d'un projet. L'éditeur affiche son contenu. Sélectionnez un mot, cliquez sur « Lien », donnez une adresse, puis ajoutez un bloc de code. Le corps de la requête, coupé, contient :

"content":"<p>Projet du cours d'interfaces : <a target=\"_blank\" rel=\"noopener noreferrer nofollow\" href=\"https://github.com/exemple/quiz-capitales\">composants </a>Vue, état partagé et stockage local du meilleur score.</p><pre><code>npm run dev</code></pre>"

L'extension de lien de Tiptap ajoute d'elle-même target="_blank", qui ouvre le lien dans un nouvel onglet. Les \" sont des guillemets échappés par l'encodage JSON.

Côté serveur, rien ne change. La règle string accepte ce texte, et $project->update() l'enregistre dans la colonne content tel qu'il est arrivé. Un éditeur vidé envoie une chaîne vide, que Laravel transforme en null, accepté par nullable.

Côté navigateur, la zone de l'éditeur est une div modifiable que Form ignore ; à chaque frappe, onUpdate recopie editor.getHTML() dans un champ caché nommé content, que Form lit à l'envoi, et la requête POST vers /admin/projets porte le HTML dans la clé content, à côté des autres champs. Côté Laravel, la règle nullable, string l'accepte et $project->update() l'enregistre tel quel dans la colonne content ; un éditeur vidé envoie une chaîne vide, qui devient null

6. Afficher le contenu avec prose

Ouvrez la fiche publique du projet. Le contenu est là, mais le titre a la taille du texte, la liste n'a pas de puces, et le bloc de code ressemble à un paragraphe. Tailwind remet à zéro les styles du navigateur, comme vous l'avez vu au chapitre 1.07.

La fiche publique sans la classe prose : le titre Mon rôle a la taille du texte, le paragraphe, les deux éléments de liste sans puces et la ligne npm run dev se suivent sans marge ni fond

Le plugin @tailwindcss/typography fournit une classe, prose, qui redonne une mise en forme lisible à tout le HTML qu'elle contient : tailles de titres, marges, puces, liens soulignés, fond pour le code. Déclarez le plugin dans resources/css/app.css, juste après les @import :

@import 'tailwindcss';

@import 'tw-animate-css';

@plugin '@tailwindcss/typography';

Avec Tailwind 4, un plugin se déclare dans le CSS avec @plugin. Le kit n'a pas de fichier tailwind.config.js, et vous n'en créez pas.

Dans resources/js/pages/projects/Show.vue, ajoutez les classes à la balise qui porte v-html :

<div class="prose dark:prose-invert" v-html="project.content"></div>

dark:prose-invert inverse les couleurs du texte quand le kit pose la classe dark sur la page. La fiche affiche maintenant le lien souligné et le bloc de code sur un fond sombre. L'éditeur utilise les mêmes classes, prose prose-sm, pour que ce que vous tapez ressemble déjà à la fiche.

La fiche publique du projet avec le même contenu : titre de section en gras plus grand, lien souligné, liste à puces, bloc de code sur fond sombre

Les huit boutons de la barre, de type button, et la balise que Tiptap écrit pour chacun : h2, h3, strong, em, ul, ol, un lien a avec target _blank et rel noopener noreferrer nofollow, pre et code. Sur la fiche, la classe prose leur donne des tailles de titres, le gras et l'italique, des puces et des numéros, un lien souligné et un fond sombre pour le code

v-html insère le HTML de la colonne tel quel, sans filtre. Au chapitre 1.07, il affichait le HTML de votre seeder. Il affiche maintenant celui de l'éditeur.

Attention

Afficher ce HTML sans le nettoyer est acceptable ici pour une seule raison : un seul auteur écrit dans content, il doit être connecté, et c'est vous. Dès qu'un visiteur peut écrire du HTML qui sera montré à d'autres (un commentaire, un message, un profil), ce HTML doit être nettoyé côté serveur avant d'être enregistré. Sinon, n'importe qui peut faire exécuter son JavaScript dans le navigateur de vos lecteurs. Cette faille s'appelle XSS, et le Tamagotchi du temps 2 vous la fera corriger.

En haut, un seul auteur, connecté, passe le middleware auth et écrit dans content ; v-html affiche ce HTML tel quel : acceptable. En bas, un visiteur malveillant envoie du HTML avec un attribut onclick ; rien ne le nettoie côté serveur avant l'enregistrement, et v-html fait exécuter son JavaScript chez chaque lecteur : c'est une faille XSS

7. Ce qui casse, et pourquoi

Le texte tapé disparaît pendant un essai

Pendant que composer run dev tourne, enregistrer un fichier PHP du dossier app/Http fait régénérer les fonctions de Wayfinder. Le terminal affiche Types generated for actions, routes, form variants, et le navigateur recharge la page. Tout ce que vous aviez tapé dans le formulaire disparaît, éditeur compris. Modifiez vos règles de validation avant de remplir un long contenu, pas pendant.

Un clic dans la barre envoie le formulaire

Si un bouton de la barre enregistre le projet, l'attribut type="button" manque dans votre copie du composant. Recopiez le fichier en entier.

La fiche n'a aucun style

Si la classe prose ne change rien, le plugin n'est pas déclaré, ou composer run dev n'a pas été relancé après npm install. Vérifiez la ligne @plugin de app.css, puis relancez.

8. Pratique : le contenu de vos projets en texte riche (25 min)

  1. Les trois paquets de Tiptap et @tailwindcss/typography, sans npm audit fix --force.
  2. RichTextEditor.vue copié en entier.
  3. Le bloc « Contenu » dans Create.vue, et dans Edit.vue avec :default-value="project.content".
  4. La ligne @plugin dans app.css, et les classes prose dark:prose-invert sur la fiche.
  5. Le contenu de vos trois projets réécrit depuis le back-office, avec un titre de niveau 2 par rubrique de votre fiche projet : contexte, rôle, problème, choix, résultat, ce que je referais.

Vérifiez dans le navigateur.

  • La barre a huit boutons, et aucun n'envoie le formulaire.
  • La modification d'un projet ouvre l'éditeur avec le contenu existant.
  • Un envoi avec un titre vide garde le texte de l'éditeur.
  • La fiche publique montre des titres, des listes à puces et un lien, mis en forme par prose.

Ce que votre portfolio contient maintenant

Un éditeur de texte riche dans les formulaires de création et de modification, qui envoie du HTML par un champ caché. Le contenu de vos projets s'écrit depuis le back-office, sans balise tapée à la main, et la fiche publique l'affiche avec les styles de prose.

À retenir

  • Un éditeur de texte riche produit du HTML, ici avec les balises de sa barre : h2, h3, p, strong, em, listes, liens et blocs de code.
  • <Form> ne lit que les champs qui ont un name, donc l'éditeur recopie son HTML dans un <input type="hidden" name="content">.
  • Un bouton placé dans un formulaire porte type="button", sinon il envoie le formulaire.
  • @plugin '@tailwindcss/typography'; dans app.css et la classe prose mettent en forme le HTML affiché par v-html.
  • v-html n'est acceptable que pour du HTML écrit par vous : le HTML d'un visiteur doit être nettoyé côté serveur.