# BlockEditor

Éditeur WYSIWYG par blocs, style Notion. Construit en interne (pas de
ProseMirror/Lexical/Tiptap). Un `contentEditable` par bloc, pas un seul
contentEditable global — c'est plus robuste et plus simple à étendre.

## Utilisation

```jsx
import { BlockEditor } from '@zen/core/shared/components';

const [blocks, setBlocks] = useState([]);
<BlockEditor value={blocks} onChange={setBlocks} label="Contenu" />
```

`value` est un **tableau de blocs JSON**. C'est la source de vérité.
`MarkdownEditor` reste disponible en parallèle pour les usages markdown.

## Format des blocs

Chaque bloc a un `id` (UUID) et un `type`. Selon le type :

| type           | champs                          | description                      |
|----------------|---------------------------------|----------------------------------|
| `paragraph`    | `content`                       | texte                            |
| `heading_1..6` | `content`                       | titre niveau 1 à 6               |
| `bullet_item`  | `content`                       | élément de liste à puces         |
| `numbered_item`| `content`                       | élément de liste numérotée       |
| `checklist`    | `content`, `checked`            | case à cocher + texte            |
| `quote`        | `content`                       | citation                         |
| `code`         | `content`                       | bloc de code (monospace)         |
| `divider`      | —                               | séparateur horizontal            |
| `image`        | `src`, `mediaSlug?`, `alt?`, `caption?`, `align`, `href`, `newTab` | image — voir [Bloc image](#bloc-image) ; `align` ∈ `left\|center\|right\|full` |

`content` est un **tableau `InlineNode[]`** depuis Phase 2 — voir ci-dessous.

Phase 3 (à venir) : `table`.

## Format `InlineNode`

Le contenu inline d'un bloc texte est un tableau plat de nœuds `text`.
Chaque nœud porte optionnellement des **marks** (formatage).

```js
[
  { type: 'text', text: 'Salut ' },
  { type: 'text', text: 'monde', marks: [{ type: 'bold' }] },
  { type: 'text', text: ' ' },
  { type: 'text', text: 'lien', marks: [{ type: 'link', href: 'https://…' }] },
]
```

### Marks supportées

| type | payload | rendu |
|---|---|---|
| `bold` | — | `<strong>` |
| `italic` | — | `<em>` |
| `underline` | — | `<u>` |
| `strike` | — | `<s>` |
| `code` | — | `<code>` (monospace, fond gris) |
| `link` | `href: string` | `<a href>` (target="_blank") |
| `color` | `color: <key> \| '#rrggbb'` | couleur du texte |
| `highlight` | `color: <key> \| '#rrggbb'` | surlignage de fond |

Le champ `color` accepte **soit** une clé de palette (`blue`, `green`, `amber`,
`red`) — résolue vers les classes Tailwind du DESIGN system —, **soit** une
string hex `#rrggbb` choisie par l'utilisateur, appliquée via `style` inline.
Voir `inline/types.js:INLINE_COLORS` et `isHexColor`.

Le contenu vide est `[]` (jamais `[{type:'text', text:''}]`).

### Helpers exportés

```js
import {
  inlineLength, inlineToPlainText, inlineFromText,
  sliceInline, concatInline, applyMark, toggleMark, removeAllMarks,
  marksAtOffset, marksInRange, INLINE_COLORS,
  isHexColor, collectUsedColors,
  blocksToHtml, blocksToPlainText, htmlToBlocks,
} from '@zen/core/shared/components/BlockEditor';
```

Tous les helpers sont **purs** : ils retournent un nouveau tableau normalisé
(nœuds adjacents identiques fusionnés, vides supprimés).

## Props

```jsx
<BlockEditor
  value={blocks}             // Block[]
  onChange={setBlocks}       // (Block[]) => void
  label, error, placeholder, disabled, className
  enabledBlocks={[...]}      // optionnel : restreindre les types disponibles
  minHeight={240}            // optionnel : hauteur minimum du conteneur (number = px, ou string CSS ex. '12rem')
/>
```

## Interactions clavier

- `/` → ouvre le menu de commandes (filtrable, ↑ ↓ Entrée pour valider, Échap pour fermer)
- `# ` → titre 1, `## ` → 2, …, `###### ` → 6
- `- ` → liste à puces
- `1. ` → liste numérotée
- `[] ` ou `[ ] ` → case à cocher
- `> ` → citation
- ` ``` ` → bloc de code
- `---` → séparateur
- `Ctrl/Cmd + B` → gras (sur sélection non vide)
- `Ctrl/Cmd + I` → italique
- `Ctrl/Cmd + U` → soulignement
- `Ctrl/Cmd + E` → code inline
- `Ctrl/Cmd + K` → lien (prompt pour l'URL)
- `Backspace` au début d'un bloc typé → repasse en paragraphe ; au début d'un paragraphe, fusionne avec le bloc précédent (uniquement si la sélection est repliée — sinon le navigateur supprime le texte sélectionné, ex. après `Ctrl+A`)
- `Entrée` sur un item de liste vide → sort de la liste
- `Ctrl/Cmd + Z` / `Ctrl/Cmd + Shift + Z` → undo / redo
- `Ctrl/Cmd + A` → 1er appui : sélectionne le contenu du bloc courant ; 2e appui : sélectionne **tous les blocs** (mode sélection multi-blocs)

## Toolbar de formatage

Quand une sélection non-vide existe dans un bloc, un toolbar flottant
apparaît au-dessus. Il propose :

- **B I U S `</>`** — marks simples (toggle)
- **A** — couleur du texte (sous-menu drop-down : palette par défaut +
  couleurs déjà utilisées dans le document + bouton `+` pour une couleur
  libre via `<input type="color">`)
- **◐** — surlignage (même structure de sous-menu)
- **🔗** — lien (sous-menu drop-down avec input URL ; ✕ pour retirer)
- **T/** — effacer tout le formatage de la sélection (supprime toutes les marks)

L'état actif est calculé à partir des marks **communes à toute la plage**
(via `marksInRange`). Toggle off si toute la plage est déjà marquée.

La toolbar est rendue en `position: absolute` à l'intérieur du container
relatif de l'éditeur et **suit la sélection lors du scroll**. Les sous-menus
(couleur, surlignage, lien) s'ouvrent **au clic** sur leur trigger et restent
ouverts jusqu'à un clic extérieur ou `Escape` — même idiome que le sous-menu
*Transformer ▸* du menu d'actions, mais en drop-down (vers le bas) plutôt qu'à
droite. Une petite flèche `▾` après le glyphe principal indique la présence du
sous-menu.

## Popover de lien

Au simple clic dans un lien existant, un popover s'ouvre sous le `<a>` avec
l'URL et la case « Ouvrir dans un nouvel onglet ». Il est rendu en
`position: absolute` dans le container de l'éditeur et **suit le lien lors
du scroll**.

## Sélection multi-blocs

Deux façons d'entrer en mode sélection multi-blocs :

- **Souris** : un drag qui traverse plusieurs blocs bascule automatiquement en sélection bloc (les contenteditables sont défocus, surlignage bleu transparent sur les blocs sélectionnés). Évite la fusion accidentelle de texte entre blocs lors d'un Backspace/Delete.
- **Clavier** : double `Ctrl/Cmd + A` (cf. ci-dessus).

En mode sélection multi-blocs :
- `Backspace` / `Delete` → supprime tous les blocs sélectionnés
- `Escape` → quitte la sélection
- `Ctrl/Cmd + A` → étend à tous les blocs (no-op si déjà tous sélectionnés)
- `Ctrl/Cmd + C` / `Ctrl/Cmd + X` → copie/coupe les blocs sélectionnés au format **HTML structuré** (titres, gras, listes, citations, …) + fallback `text/plain`. Collable dans Word, Google Docs, Slack, ou un autre `BlockEditor`.
- `Ctrl/Cmd + V` → remplace les blocs sélectionnés par le contenu HTML du presse-papier (parsé par `htmlToBlocks`).
- frappe d'un caractère imprimable → remplace les blocs sélectionnés par un nouveau paragraphe contenant ce caractère
- clic dans l'éditeur → quitte la sélection

## Poignées au survol

Chaque bloc affiche au survol deux boutons à gauche, chacun ouvrant un
dropdown manuel (state local + fermeture sur clic extérieur / `Escape`) :

- `Add01Icon` (« + ») — ouvre le **menu d'insertion** : liste tous les types
  disponibles (filtrés par `enabledBlocks`) avec une icône en boîte. Le clic
  sur un type insère le bloc juste en dessous du bloc courant.
- `DragDropVerticalIcon` — double rôle : presser-glisser pour réordonner
  (drag-and-drop HTML5 natif), simple clic pour ouvrir le **menu d'actions**.

Les icônes proviennent de [`src/shared/icons/index.js`](../../icons/index.js).

## Menu d'actions du bloc

- **Transformer ▸** — sous-menu qui s'ouvre au clic (et se ferme sur clic
  extérieur ou `Escape`), listant les types de blocs texte disponibles
  (paragraphe, titres 1 à 6, listes, citation, code)
  avec une icône en boîte. Cliquer sur un type remplace le bloc courant en
  conservant son contenu inline. L'item est masqué pour les blocs non-texte
  (image, séparateur). Le filtrage respecte la prop `enabledBlocks`.
- **Dupliquer** — insère une copie du bloc juste en dessous (nouvel `id`).
- **Supprimer** — retire le bloc (équivalent à `Backspace` au début d'un bloc
  vide), avec focus replacé sur le bloc précédent.

Le drag (`mousedown` + déplacement) et le clic (ouverture du menu) cohabitent
sur le même bouton : si un `dragstart` réel se produit, un drapeau interne
(`justDraggedRef`) supprime l'ouverture du menu lors du `click` qui suit.
Sinon (clic sans déplacement), le menu s'ouvre normalement. Les dropdowns
sont des composants maison (pas de Headless UI ici) car `MenuButton` ouvrait
sur `pointerdown`, ce qui empêchait le clic-maintenu nécessaire au drag.

## Identité visuelle partagée

Les quatre dropdowns (menu d'insertion, menu d'actions du bloc, toolbar
de formatage, popover de lien) partagent les classes `BOX_CLASS`,
`ITEM_CLASS`, `ITEM_DANGER_CLASS`, `ICON_BTN_CLASS` et `SEPARATOR_CLASS`
exportées par [`inline/menuStyles.js`](./inline/menuStyles.js). Pas de
wrapper de composant — juste des constantes Tailwind, parce que les trois
formes (liste verticale, barre horizontale, formulaire) sont trop
différentes pour qu'une abstraction soit rentable.

## Étendre — enregistrer un bloc custom

```js
import { registerBlock, newBlockId } from '@zen/core/shared/components/BlockEditor';

registerBlock({
  type: 'kpi',
  label: 'KPI',
  icon: <ChartIcon width={18} height={18} />,
  keywords: ['kpi', 'metric', 'stat'],
  isText: false,
  create: () => ({ id: newBlockId(), type: 'kpi', value: 0 }),
  Component: ({ block, onChange, disabled }) => (
    <input
      type="number"
      value={block.value}
      disabled={disabled}
      onChange={(e) => onChange({ value: Number(e.target.value) })}
    />
  ),
});
```

Le nouveau type apparaît automatiquement dans le slash menu de tout
`<BlockEditor />` rendu après l'enregistrement. Pour les blocs **texte**, on
fournit à la place `isText: true`, `textTag`, `textClassName`, et
optionnellement `renderPrefix({ block, onPatch, index, numberedIndex, disabled })`
pour un préfixe (puce, numéro, case à cocher). Le `content` initial doit
être `[]` (un `InlineNode[]` vide).

## Architecture interne

```
BlockEditor.client.js     orchestrateur : value/onChange, undo, slash menu, drag-drop, toolbar
Block.client.js           wrapper d'un bloc : handles, contentEditable, paste sanitize
SlashMenu.client.js       menu flottant filtrable
blockRegistry.js          map type → définition, API publique d'extension
blockTypes/               un fichier par type built-in
inline/types.js           InlineNode[] : palette, helpers purs (slice, concat, marks)
inline/serialize.js       DOM ↔ InlineNode[] (inlineToDom / domToInline)
inline/Toolbar.client.js  barre flottante de formatage
utils/ids.js              UUID pour les blocs
utils/caret.js            gestion du caret (multi-Text-nodes)
```

## Copier-coller avec formatage

Le presse-papier transporte deux MIME en parallèle : `text/html` (structure
+ formatage) et `text/plain` (fallback). Côté éditeur :

- **Copy / Cut** : `blocksToHtml(selected)` produit un HTML standard
  (`<h1>…<h6>`, `<p>`, `<ul>/<ol><li>`, `<blockquote>`, `<pre><code>`,
  `<hr>`, `<figure><img>`). Pour les sélections multi-blocs (focus
  défocus), on passe par l'API async `navigator.clipboard.write` avec un
  `ClipboardItem` qui inclut les deux MIME.
- **Paste dans un bloc** : `Block.handlePaste` lit d'abord la MIME
  propriétaire `text/_notion-blocks-v3-production` (parsée par
  `notionJsonToBlocks`) — Notion conserve ses types natifs (`to_do`,
  `sub_sub_header`, …) là où le HTML aplatit `to_do` en `<ul><li>`. À
  défaut, retombe sur `text/html` parsé par `htmlToBlocks`. Si un seul paragraphe est produit,
  son contenu inline est splicé au caret. Sinon le bloc courant est
  coupé en deux et les blocs collés sont insérés entre les deux moitiés
  (avec fusion tête/queue si extrémités = paragraphe).
- **Paste en sélection multi-blocs** : `navigator.clipboard.read()` lit
  le HTML du presse-papier, le convertit, et remplace les blocs
  sélectionnés.

Tags HTML reconnus en entrée : `<h1>`–`<h6>`, `<p>`, `<ul>/<ol><li>`,
`<ul data-checklist>`, `<ul class="to-do-list">` (Notion), `<ul class="task-list">` (GitHub-flavored markdown), ou `<li>` contenant `<input type="checkbox">` ou `<div class="checkbox checkbox-on/off">` (Notion),
`<blockquote>`, `<pre>`, `<code>`, `<hr>`, `<figure>`, `<img>`, plus
toutes les marks de `domToInline` (`<strong>/<b>`, `<em>/<i>`, `<u>`,
`<s>/<strike>/<del>`, `<code>`, `<a href>`, `<span data-color>` /
`<span data-highlight>`).

Les wrappers de Word / Google Docs (`<b id=...>`, `<div>` de mise en
page) sont traversés pour atteindre les éléments block-level
descendants. Les styles CSS inline (`font-weight`, `font-style`,
`text-decoration`) sont également lus en plus des tags sémantiques.

## Bloc image

Le formulaire d'insertion accepte deux sources :

- **URL externe** : champ libre + bouton « Insérer ».
- **Bibliothèque media** : bouton « Choisir un média » qui ouvre le `MediaPicker`
  de `@zen/core/features/media`. À la sélection, le bloc reçoit
  `block.src = "/zen/api/media/file/<slug>"` **et** `block.mediaSlug = "<slug>"`.
  Les nouveaux uploads passent par ce picker en visibilité `public` (les blocs
  image vivent dans du contenu publié — un média privé serait illisible côté
  frontal).

### Liaison avec la médiathèque (`mediaSlug`)

Quand un bloc image est lié à un média (`mediaSlug` présent), le **média est
la source unique de vérité** pour `alt` et `caption`. Le bloc ne stocke ni
l'un ni l'autre. Toute mise à jour côté médiathèque est immédiatement
répercutée sur tous les blocs qui référencent ce média, sans toucher aux
contenus.

Pour les **URL externes** (pas de `mediaSlug`), `alt` et `caption` vivent
sur le bloc — il n'y a pas d'autre source possible.

### Affichage et édition des métadonnées

Sous l'image, on n'affiche **que la légende**, et **uniquement si elle
existe**. Pas de placeholder « Aucune légende », pas de bandeau gris, pas
d'alt visible (l'alt vit sur l'attribut `<img alt>`). L'objectif est que le
rendu en édition reflète exactement ce que verra le lecteur final.

L'édition des métadonnées passe par un **bouton dédié** dans la toolbar
flottante de l'image (icône `Settings02Icon`) :

- Image **liée à la médiathèque** → ouvre `MediaDetailsModal`, le **même
  composant** que celui de la page admin Médias (visibilité, alt, caption,
  URL, suppression). Toute édition met à jour `zen_media` directement.
- Image **URL externe** → ouvre une modale légère avec deux champs (alt,
  caption) qui patchent le bloc local.

`MediaDetailsModal` est exposé en entry public `@zen/core/features/media/details-modal`
et accepte soit `media={media}` (objet déjà chargé), soit `slug="…"` (le bloc
image l'utilise — résolution via `GET /zen/api/media/by-slug/:slug`).

Champs internes au composant (préfixe `_`, jamais persistés) :

- `_mediaAlt` / `_mediaCaption` : snapshot capté à l'insertion via le
  `MediaPicker`, pour affichage immédiat dans le bandeau lecture-seule sans
  appel réseau.
- `_resolvedAlt` / `_resolvedCaption` : injectés au rendu serveur par
  `enrichBlocksWithMedia` (cf. ci-dessous).

### Helpers serveur — `mediaLink.server.js`

Trois helpers à appeler depuis les actions/routes serveur du module qui
héberge le contenu BlockEditor :

```js
import {
  normalizeImageBlocks,
  enrichBlocksWithMedia,
  syncBlockImageReferences,
} from '@zen/core/shared/components/BlockEditor/mediaLink';

// Au save :
const normalized = await normalizeImageBlocks(blocks);
await persistDocument(documentId, normalized);
await syncBlockImageReferences({
  sourceType: 'post',  // ou 'page', '@zen/module-shop:product', …
  sourceId: documentId,
  blocks: normalized,
});

// Au render (page SSR) :
const blocks = await loadDocument(slug);
const enriched = await enrichBlocksWithMedia(blocks);
return <BlockEditor value={enriched} disabled />;
```

`normalizeImageBlocks` :
- Lazy upgrade : extrait `mediaSlug` depuis `src` quand l'URL matche
  `/zen/api/media/file/<slug>`.
- Back-fill : si le média n'a pas encore d'`alt_text` / `caption` mais que le
  bloc en porte (legacy), remonte la valeur côté `zen_media`.
- Strict : nettoie `alt` et `caption` du bloc dès qu'un `mediaSlug` est résolu.

`enrichBlocksWithMedia` : une seule requête batch pour tous les `mediaSlug`
du document, attache `_resolvedAlt` / `_resolvedCaption`. Le composant de
rendu lit ces champs en priorité.

`syncBlockImageReferences` : synchronise `zen_media_references` avec
`field = 'block_image'`. La contrainte FK `ON DELETE RESTRICT` empêche
ensuite la suppression d'un média référencé par un bloc image tant que le
document hôte existe. Pensez à appeler `detachAllForSource` (de
`@zen/core/features/media`) à la suppression du document hôte.

Une fois l'URL insérée, l'image affiche au survol une **toolbar flottante**
(coin haut-droit) reprenant le style `BOX_CLASS` partagé :

- **Alignement** : 4 boutons (gauche / centre / droite / pleine largeur).
  Persisté dans `block.align`. Le wrapper applique `justify-content` selon
  la valeur ; `'full'` passe l'image à `width: 100%` (l'image étire la
  largeur du conteneur de bloc, pas du viewport).
- **Lien** : ouvre un mini-popover (input URL + case « nouvel onglet »).
  Persisté dans `block.href` et `block.newTab`. Côté éditeur l'image n'est
  jamais wrappée dans `<a>` pour ne pas piéger les clics ; en mode `disabled`
  et à l'export HTML (`blocksToHtml`), l'image est wrappée dans `<a>` avec
  `target="_blank" rel="noopener noreferrer"` quand `newTab` est vrai.
- **Métadonnées** (`Settings02Icon`) : ouvre la modale d'édition alt/caption.
  Pour une image liée à la médiathèque, c'est `MediaDetailsModal` (édition
  directe du média en BD). Pour une URL externe, c'est une modale légère qui
  patche `block.alt` / `block.caption`.
- **Remplacer** : remet le formulaire URL avec l'URL courante préremplie.
  Conserve `mediaSlug`, `align`, `href`. Échap pour annuler.
- **Supprimer** : vide tous les champs ; le formulaire URL réapparaît.

Sérialisation HTML : `<figure data-align="…"><a href><img></a><figcaption></figcaption></figure>`.
Le parser inverse (`htmlToBlocks`) lit `data-align`, et descend dans `<a>`
quand l'`<img>` y est imbriqué pour récupérer `href` / `target`. Si le `src`
de l'image matche `/zen/api/media/file/<slug>`, il reconstitue `mediaSlug`
pour préserver la liaison média lors d'un copier-coller interne.

## Limitations connues

- Pas d'imbrication de listes.
- Image : insertion par URL externe ou via le `MediaPicker` du module
  `media` (les uploads y sont gérés). La caption est une string plate
  (pas de formatage inline pour l'instant).
- Tables : Phase 3.
