# HtmlBuilder — documentation

Module JS "no-code" de construction de HTML : éditeur de blocs par glisser-déposer,
avec deux moteurs de rendu (email compatible tous clients mail / web CSS moderne).
Vanilla JS + Alpine.js, aucun build tool, livré comme fichier(s) statique(s).

Source : `/var/www/js/html-builder/`. Déployé sur :
- `https://js.letareau.fr/html-builder/` (canonique)
- `https://test.letareau.fr/html-builder/` (copie de démo, à resynchroniser manuellement — voir [Déploiement](#déploiement))

> Cette doc est mise à jour à chaque nouveauté ajoutée au module. Le
> changelog en bas de page liste les évolutions par version.

---

## Installation

```html
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://js.letareau.fr/html-builder/dist/html-builder.js"></script>
```

Alpine.js est un **pré-requis de la page hôte**, pas embarqué dans le bundle — à charger avant d'instancier `HtmlBuilder`.

Pour afficher un rendu en mode `web` (voir [Modes de rendu](#modes-de-rendu)), charger aussi la feuille de style de sortie sur la page qui **affiche** le HTML généré (pas nécessaire pour l'éditeur lui-même, isolé en Shadow DOM) :

```html
<link rel="stylesheet" href="https://js.letareau.fr/html-builder/dist/html-builder-web-output.css">
```

## Démarrage rapide

```html
<div id="editor-mount"></div>
<script>
const builder = new HtmlBuilder(document.getElementById('editor-mount'), {
    mode: 'email',
    blocks: [],
    onChange: (blocks) => console.log('blocs modifiés', blocks),
});

// Plus tard, au submit d'un formulaire par ex. :
document.querySelector('[name=body_html]').value = builder.getHTML();
document.querySelector('[name=body_text]').value = builder.getText();
document.querySelector('[name=blocks_json]').value = JSON.stringify(builder.getBlocksJSON());
</script>
```

---

## `new HtmlBuilder(container, options)`

Monte l'éditeur (palette + canvas + panneau de propriétés) en Shadow DOM dans `container`. Isolation CSS totale dans les deux sens vis-à-vis de la page hôte.

### Options

| Option | Type | Défaut | Description |
|---|---|---|---|
| `mode` | `'email' \| 'web'` | `'email'` | Moteur de rendu actif (voir [Modes de rendu](#modes-de-rendu)) |
| `blocks` | `Array` | `[]` | État initial des blocs (format `blocks_json`, voir plus bas) |
| `editable` | `boolean` | `true` | `false` = éditeur en lecture seule (palette masquée, `inert`) |
| `allowedBlocks` | `string[]` | tous | Restreint la palette (top-level ET colonnes) à ces types |
| `theme` | `object` | — | Override du thème par défaut, voir [Thème](#thème) |
| `onChange` | `(blocks) => void` | — | Appelé à chaque mutation de l'état des blocs |
| `onImageRequest` | `() => Promise<string>` | — | Si fourni, ajoute un bouton "Choisir…" sur le champ image qui délègue le choix à l'hôte (upload, media library…) ; sinon repli sur saisie d'URL manuelle |
| `snippets` | `Array` | — | État initial des snippets (voir [Snippets](#snippets)) |
| `onSnippetsChange` | `(snippets) => void` | — | Delegation de la persistance des snippets à l'hôte |
| `snippetsStorageKey` | `string` | `'htmlbuilder_snippets_v1'` | Clé localStorage utilisée si ni `snippets` ni `onSnippetsChange` ne sont fournis |
| `maxWidth` | `number` | `600` (email) / `800` (web) | Largeur max du conteneur racine du HTML généré |

Le widget ne fait **aucun appel réseau** lui-même (sauf `onImageRequest`, fourni par l'hôte).

### Méthodes d'instance

| Méthode | Retour | Description |
|---|---|---|
| `getBlocksJSON()` | `Array` | État brut des blocs, à sauvegarder côté hôte |
| `setBlocksJSON(blocks)` | — | Recharge un état (ex: "charger un template"). Migre automatiquement l'ancien format de colonnes si besoin |
| `getHTML(mode?)` | `string` | Rendu HTML. `mode` optionnel (`'email'`/`'web'`) pour un export ponctuel sans changer le mode courant |
| `getText()` | `string` | Rendu texte brut (identique quel que soit le mode) |
| `setMode(mode)` | — | Change le mode de rendu en live. Ne modifie **jamais** `blocks[]` — seule l'interprétation change |
| `getMode()` | `'email' \| 'web'` | Mode courant |
| `getTheme()` | `object` | Thème actif (fusionné avec les défauts) |
| `setTheme(theme)` | — | Change le thème en live (repart toujours de `DEFAULT_THEME`, pas d'accumulation) |
| `destroy()` | — | Détache le DOM et les listeners. Important si le widget est monté/démonté dynamiquement dans une SPA hôte |

### Méthodes statiques

| Méthode | Description |
|---|---|
| `HtmlBuilder.registerBlockType(def)` | Enregistre un type de bloc — voir [Blocs personnalisés](#blocs-personnalisés). **Doit être appelée avant** `new HtmlBuilder(...)` pour que le type apparaisse dans la palette de l'instance créée ensuite. Registry global au module (pas par-instance) |
| `HtmlBuilder.utils` | `{ escHtml, escAttr, capitalizeFirst }` — utilitaires stables pour les fichiers de blocs externes (voir plus bas), qui tournent dans leur propre portée et n'ont pas accès aux fonctions internes du bundle |

---

## Modes de rendu

Un seul éditeur de blocs, deux moteurs de rendu au choix — `blocks[]` reste strictement le même JSON dans les deux cas, seule l'interprétation (`getHTML()`) diffère :

- **`email`** : `<table>` + styles inline uniquement (compatible Outlook/Gmail/Apple Mail...). Largeur fixe (`maxWidth`, 600px par défaut).
- **`web`** : classes CSS modernes (`hbw-*`, définies dans `dist/html-builder-web-output.css`), responsive. Largeur fluide (`maxWidth`, 800px par défaut).

## Format `blocks_json`

```js
[
  { id: 1, type: 'heading', content: { text: 'Titre', level: 'h2', align: 'left' } },
  { id: 2, type: 'text', content: { content: '<p>...</p>', align: 'left' } },
]
```

`id` est un entier unique dans **tout** l'arbre (top-level + sous-blocs de colonnes confondus — un seul compteur partagé). `content` est spécifique au `type`.

### Types de blocs du cœur

| Type | `content` | Notes |
|---|---|---|
| `text` | `{ content: string (HTML riche), align }` | Édition WYSIWYG basique dans le canvas (gras/italique/souligné/liste/lien) |
| `heading` | `{ text, level: 'h1'\|'h2'\|'h3', align }` | |
| `button` | `{ text, url, variant?, align, fullWidth, styleOverrides? }` | Voir [Thème](#thème) |
| `image` | `{ src, alt, width, align, linkUrl }` | `width` accepte `100%`, `300px`, etc. |
| `divider` | `{ style: 'solid'\|'dashed'\|'dotted', color, width }` | |
| `spacer` | `{ height (px) }` | |
| `banner` | `{ title, subtitle?, bgColor, bgColor2, textColor }` | Bannière dégradée (en-tête d'email type "carte de bienvenue"). L'emoji se tape directement dans `title`. Défauts = couleurs de marque `#6750A4`/`#8C6A9E`. `icon` (legacy, un champ séparé abandonné) reste lu en rendu pour ne pas casser les anciennes données |
| `callout` | `{ content (HTML riche), align, borderMode: 'preset'\|'custom'\|'none', borderSides?, borderWidth?, linkColors, accentColor, bgColor? }` | Comme `text`, avec fond + bordure. `linkColors` (défaut `true`) dérive le fond depuis `accentColor` |
| `code` | `{ label?, code, bgColor, codeColor }` | Affichage d'un code (parrainage, retrait, lien de compte…) |
| `footer` | `{ text, bgColor, textColor }` | Pied de page discret |
| `columns` | `{ count, columns: [{ blocks: [...] }] }` | Voir [Colonnes](#colonnes-imbriquées) |
| `html` | `{ code, textVersion? }` | Code HTML brut, **aucune sanitization** (confiance totale dans l'auteur du contenu) |

Le type `social` (réseaux sociaux) a été retiré du cœur — voir [Blocs personnalisés](#blocs-personnalisés) pour le charger.

---

## Thème

```js
new HtmlBuilder(el, {
    theme: {
        colors: { primary: '#123456', onPrimary: '#FFFFFF' },
        variants: {
            button: {
                primary: { /* hérite de colors.primary si non précisé */ },
                secondary: { bgColor: '#E8DEF8', textColor: '#1D192B', borderRadius: 8 },
                cta: { label: 'Appel à action', bgColor: '#FF5722', textColor: '#FFFFFF', borderRadius: 999 },
            },
        },
    },
});
```

Tokens par défaut (`DEFAULT_THEME` dans `src/core/theme.js`) : `colors.{primary,onPrimary,text,border}`, `font.{family,size,headingFamily}`, `radius.default`, `variants.button.{primary,secondary,outline}`.

`font.headingFamily` est optionnel : si absent (`null` par défaut), le bloc `heading` hérite de `font.family` (comportement inchangé). Utile pour une police de titres distincte du corps de texte (ex. une serif de marque) sans affecter le reste du contenu :

```js
theme: { font: { family: '"Source Sans 3", sans-serif', headingFamily: '"Fraunces", Georgia, serif' } }
```

**Résolution calculée à la lecture**, jamais réécrite dans les données stockées — changer le thème rethème instantanément tous les blocs qui utilisent une variante donnée. Priorité (du plus faible au plus fort) :

1. Tokens de base du thème (`colors.primary`, etc.)
2. Variante nommée (`content.variant`, défaut `'primary'`)
3. Rétrocompatibilité V1 implicite (si `content.bgColor` etc. sont présents et qu'aucun `variant`/`styleOverrides` n'est défini — cas de tous les `blocks_json` créés avant l'introduction du thème)
4. `content.styleOverrides` — override libre par instance de bloc (échappatoire au-dessus de la variante), modifiable via le bouton "↺" du panneau de propriétés pour revenir au style du thème

**V2 : variantes nommées uniquement sur le bloc `button`.** Infra réutilisable plus tard pour `heading`/`divider` si besoin.

### Chrome de l'éditeur (UI) vs thème du contenu

Le thème ci-dessus ne pilote que le **HTML généré** (`getHTML()`). L'apparence de l'éditeur lui-même (palette, canvas, panneau de propriétés) est une préoccupation séparée, pilotée par des **custom properties CSS** sur `src/css/editor.css` — `--hb-primary`, `--hb-on-primary`, `--hb-surface`, `--hb-border`, `--hb-border-hover`, `--hb-bg`, `--hb-text`, `--hb-radius`, `--hb-font`. Un hôte peut les redéfinir sur le conteneur passé au constructeur (ou plus haut dans le DOM) : les custom properties CSS héritent à travers la frontière du Shadow DOM, donc pas besoin de forker le CSS pour matcher une charte graphique hôte.

```css
#mon-mount-editeur {
    --hb-primary: #1B6B2E;
    --hb-surface: #FEF7FF;
    --hb-border: #CAC4D0;
    --hb-font: 'Inter', sans-serif;
}
```

---

## Colonnes imbriquées

Une colonne est un vrai mini-éditeur (mini-palette + mini-canvas), avec les mêmes types de blocs que le canvas principal — **sauf `columns` lui-même** (imbrication limitée à 1 niveau, pas de colonnes dans une colonne).

```js
{
  id: 1, type: 'columns',
  content: {
    count: 2,
    columns: [
      { blocks: [{ id: 2, type: 'heading', content: {...} }] },
      { blocks: [] },
    ],
  },
}
```

Un ancien `blocks_json` avec l'ancien format (`content.columns[i].content` = string HTML figée) est **migré automatiquement** au chargement (`new HtmlBuilder`/`setBlocksJSON`) vers un bloc `html` unique contenant cette string — aucune perte de contenu, migration idempotente.

Hors scope V2 : pas de drag & drop dans les mini-canvases (réordonnancement via ↑/↓), pas d'édition riche du texte imbriqué (textarea simple).

---

## Blocs personnalisés

```js
HtmlBuilder.registerBlockType({
    type: 'video',                          // requis, unique
    icon: '▶',                              // affiché dans la palette
    label: 'Vidéo',
    allowedInColumn: true,                   // false pour l'exclure de la palette imbriquée (comme "columns")
    defaultContent: () => ({ url: '' }),
    fields: [                                // panneau de propriétés générique (optionnel)
        { key: 'url', label: 'URL YouTube/Vimeo', type: 'text' },
    ],
    renderEmail: (content, ctx) => `<tr><td>[Vidéo non supportée en email : ${content.url}]</td></tr>`,
    renderWeb: (content, ctx) => `<div class="hbw-video"><iframe src="${content.url}"></iframe></div>`,
    renderText: (content) => `[Vidéo : ${content.url}]`,
}); // AVANT new HtmlBuilder(...)
```

`fields[].type` disponibles pour le panneau générique : `text`, `number`, `color`, `checkbox`, `select` (+ `options: [[value, label], ...]`), `align`, `variant` (+ `blockType`, voir [Thème](#thème)). Un bloc sans `fields` (`null`) n'a pas de panneau générique — soit il n'a pas besoin d'être configuré, soit il fournit sa propre UI (hors scope V2 : pas de panneau/preview canvas riche custom, juste un repli texte générique dans le canvas).

`ctx` passé aux fonctions de rendu : `{ theme, options }`.

**Exemple réel : `src/blocks/social.js`** — le bloc "Réseaux" a été retiré du cœur et réenregistré via cette API, dans un fichier séparé buildé indépendamment (`dist/html-builder-block-social.js`), à charger en plus si besoin :

```html
<script src="https://js.letareau.fr/html-builder/dist/html-builder.js"></script>
<script src="https://js.letareau.fr/html-builder/dist/html-builder-block-social.js"></script>
```

Un `blocks_json` contenant un type non enregistré (ex: bloc "social" sans que le fichier soit chargé) échoue proprement au rendu (chaîne vide + `console.error`), sans planter le reste.

## Snippets (no-code)

Un bloc déjà configuré peut être sauvegardé comme "snippet" réutilisable, sans JS :
- Bouton 💾 sur chaque bloc du canvas → prompt pour le nom → apparaît dans la section "Snippets" de la palette.
- Clic sur un snippet → l'insère (ids frais, pas de collision avec l'original).
- Persistance : `options.snippets`/`options.onSnippetsChange` (délégué à l'hôte, même principe que `blocks`/`onChange`) ou `localStorage` par défaut.

Hors scope V2 : un seul bloc à la fois (pas de sélection multiple/plage de blocs).

---

## Architecture des sources

Pas de build tool — `build.sh` concatène les fichiers `src/` dans un ordre explicite en un seul `dist/html-builder.js` (IIFE). Éditer les sources dans `src/`, jamais `dist/` directement.

```
src/
├── core/
│   ├── escape.js        # escHtml/escAttr/capitalizeFirst
│   ├── registry.js      # BlockRegistry (Map globale des types de blocs)
│   ├── theme.js          # DEFAULT_THEME, mergeTheme, resolveButtonStyle
│   ├── blocks-builtin.js # Enregistre les 8 types du cœur (sans "social")
│   ├── state.js          # BlockStore : état des blocs, indépendant du DOM
│   ├── snippets.js       # SnippetStore
│   └── widget.js         # Classe HtmlBuilder, point d'entrée public
├── renderers/
│   ├── renderer-email.js # Dispatch générique vers le registry (mode email)
│   ├── renderer-web.js   # Dispatch générique vers le registry (mode web)
│   └── renderer-text.js  # Dispatch générique vers le registry (texte brut)
├── ui/
│   ├── editor.template.js # HTML de l'éditeur (string, directives Alpine)
│   └── editor.alpine.js   # Objet x-data Alpine (état + méthodes)
├── css/editor.css         # CSS de l'éditeur (embarqué au build, Shadow DOM)
└── blocks/social.js       # Bloc optionnel, build séparé
```

## Déploiement

```sh
cd /var/www/js/html-builder
./build.sh
```

`js.letareau.fr` sert directement `/var/www/js/` (déploiement = juste lancer `build.sh`, rien à copier). La copie de démo sur `test.letareau.fr` doit être resynchronisée manuellement :

```sh
rsync -a --delete dist/ /var/www/test/html-builder/dist/
cp -f demo/index.html /var/www/test/html-builder/demo/index.html
```

---

## Changelog

### V2.5.3
- **Fix format "card"** : le rendu ajoutait une page de fond gris clair (`pageBackground`, 32px de marge) autour de la carte, absente d'`EmailTemplate::card()` (PHP) — les mails composés en "mode carte" dans l'éditeur ne reproduisaient donc pas fidèlement le style des mails transactionnels codés à la main (ex. "Bienvenue chez West Up"), contrairement à ce que visait le format depuis son introduction en V2.5. Page de fond retirée ; largeur par défaut alignée sur l'original (`520px` au lieu de `600px`). L'option `pageBackground` n'existe plus (la carte n'a plus de fond de page à colorer) ; `cardOpts.maxWidth` reste disponible pour l'ajuster au besoin.

### V2.5.2
- **Bloc `banner`** : le champ "icône" séparé du titre a été retiré (jugé être une case en trop pour un gain marginal) — l'emoji se tape directement au début de `title`. Les campagnes déjà enregistrées avec l'ancien format à 2 champs continuent de s'afficher correctement (fusion automatique au premier clic dans le titre).

### V2.5.1
- **Fix format "card"** : la bordure/le fond/l'arrondi étaient posés sur le `<table>` racine via `overflow:hidden`, peu fiable selon les clients mail. Déplacés sur un `<td>` (technique email "bulletproof" standard).
- **Fix bouton "code"** (bloc `text`/`callout`) : `window.getSelection()` ne voit pas fiablement les sélections à l'intérieur du Shadow DOM de l'éditeur (limite Chromium) — utilise maintenant la racine du nœud réellement actif (`document.activeElement.getRootNode()`), avec repli sur `window.getSelection()`.

### V2.5
- **Format "card"** pour le rendu email : `renderEmail(blocks, { card: true })` (ou `{ borderRadius, pageBackground }`) encadre le contenu (fond blanc, bordure, coins arrondis) sur une page de fond gris clair — reproduit le style "Bienvenue chez West Up" en un seul réglage, sans changer les blocs eux-mêmes. Alternative : format "classique" (défaut, sans `card`).
- **Bloc `callout` revu** : contenu riche (édition directe dans le canvas, même barre d'outils que `text`, y compris le nouveau style `code` en ligne) au lieu d'un simple textarea. Bordure au choix : `preset` (accent à gauche, comportement V2.4), `custom` (côtés/épaisseur/couleur libres), `none`. `linkColors` dérive automatiquement une teinte de fond claire depuis une seule couleur d'accent.
- **Édition directe dans le canvas** pour `banner`/`code`/`footer` (comme `heading`), en plus du panneau de propriétés.
- Bloc `text` (et `callout`) : nouveau bouton de mise en forme "code" dans la barre d'outils (style `/lier XXXXXX` des mails Discord).
- Nouveau champ générique `showIf: (content) => boolean` sur une entrée de `fields[]`, pour n'afficher un champ que sous condition (ex: options de bordure de `callout`).

### V2.4
- **4 nouveaux blocs du cœur** : `banner`, `callout`, `code`, `footer` — reprennent le vocabulaire visuel des mails transactionnels codés à la main (bannière dégradée, encart à bordure, affichage de code, pied de page), avec les couleurs de marque en défaut, pour pouvoir recomposer ces mails via l'éditeur plutôt qu'en PHP brut.
- Nouveau type de champ générique `textarea` (panneau de propriétés).

### V2.3
- **Police des titres distincte du corps** : `theme.font.headingFamily` (optionnel, replie sur `font.family` si absent) — le bloc `heading` l'applique désormais en email et en web. Ajouté pour matcher une charte publique où les titres sont en serif (ex. "Fraunces") et le corps en sans-serif.

### V2.2
- **Fix thème email** : `renderEmail()` codait en dur la police (`Helvetica Neue`) et la couleur de texte (`#333333`) du `<table>` racine au lieu d'utiliser `theme.font`/`theme.colors.text`. Seul le bouton était réellement thémable côté email. Corrigé — `theme.font.family`, `theme.font.size` et `theme.colors.text` s'appliquent maintenant au conteneur racine du rendu email, comme documenté dans [Thème](#thème).

### V2.1
- **Theming du chrome de l'éditeur** : `src/css/editor.css` expose désormais des custom properties CSS (`--hb-primary`, `--hb-surface`, `--hb-border`, `--hb-font`, etc.) avec fallback sur le look par défaut, surchargeables par l'hôte sans forker le CSS (héritent à travers le Shadow DOM). Utilisé pour intégrer l'éditeur dans le Desk (westup.fr) avec sa charte Material 3.

### V2
- **Thème** : tokens par défaut + variantes nommées (bouton) + override libre par instance de bloc (`styleOverrides`). Rétrocompatible avec les couleurs littérales V1.
- **Blocs personnalisés** : `HtmlBuilder.registerBlockType()`, registry global. Bloc "Réseaux" retiré du cœur, redevenu un exemple de bloc optionnel (`src/blocks/social.js`).
- **Snippets no-code** : sauvegarder/réinsérer un bloc configuré, sans JS.
- **Colonnes imbriquées** : chaque colonne est un mini-éditeur (1 niveau de profondeur), migration automatique de l'ancien format.

### V1
- Éditeur de blocs Alpine.js en Shadow DOM, 9 types de blocs (text, heading, button, image, divider, spacer, social, columns, html).
- Deux moteurs de rendu : email (table + inline styles) et web (CSS moderne).
- Livré comme `dist/html-builder.js` unique, sans build tool, Alpine.js en pré-requis hôte.
