diff --git a/src/features/admin/devkit/ComponentsPage.client.js b/src/features/admin/devkit/ComponentsPage.client.js index 0e6947e..b6f6c5c 100644 --- a/src/features/admin/devkit/ComponentsPage.client.js +++ b/src/features/admin/devkit/ComponentsPage.client.js @@ -218,11 +218,20 @@ export default function ComponentsPage() { function BlockEditorDemo() { const [blocks, setBlocks] = useState([ - { id: 'demo-1', type: 'heading_1', content: 'Bienvenue dans BlockEditor' }, - { id: 'demo-2', type: 'paragraph', content: "Tapez '/' pour ouvrir le menu de commandes." }, - { id: 'demo-3', type: 'bullet_item', content: 'Glissez la poignée ⋮⋮ pour réordonner' }, - { id: 'demo-4', type: 'bullet_item', content: 'Tapez `# ` au début pour un titre, `- ` pour une puce' }, - { id: 'demo-5', type: 'paragraph', content: '' }, + { id: 'demo-1', type: 'heading_1', content: [{ type: 'text', text: 'Bienvenue dans BlockEditor' }] }, + { id: 'demo-2', type: 'paragraph', content: [ + { type: 'text', text: "Tapez " }, + { type: 'text', text: "'/'", marks: [{ type: 'code' }] }, + { type: 'text', text: ' pour ouvrir le menu, ou ' }, + { type: 'text', text: 'sélectionnez', marks: [{ type: 'bold' }] }, + { type: 'text', text: ' pour ' }, + { type: 'text', text: 'mettre en forme', marks: [{ type: 'italic' }, { type: 'color', color: 'blue' }] }, + { type: 'text', text: '.' }, + ] }, + { id: 'demo-3', type: 'checklist', checked: true, content: [{ type: 'text', text: 'Format inline (gras, italique, couleur, lien)' }] }, + { id: 'demo-4', type: 'checklist', checked: false, content: [{ type: 'text', text: 'Bloc image (URL uniquement en Phase 2)' }] }, + { id: 'demo-5', type: 'bullet_item', content: [{ type: 'text', text: 'Glissez la poignée ⋮⋮ pour réordonner' }] }, + { id: 'demo-6', type: 'paragraph', content: [] }, ]); return (
{error}
)} diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md index dd1b8e8..6e20282 100644 --- a/src/shared/components/BlockEditor/README.md +++ b/src/shared/components/BlockEditor/README.md @@ -16,23 +16,70 @@ const [blocks, setBlocks] = useState([]); `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 (Phase 1) +## Format des blocs Chaque bloc a un `id` (UUID) et un `type`. Selon le type : -| type | champs | description | -|----------------|---------------|----------------------------| -| `paragraph` | `content` | texte brut | -| `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 | -| `quote` | `content` | citation | -| `code` | `content` | bloc de code (monospace) | -| `divider` | — | séparateur horizontal | +| 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`, `alt`, `caption` | image (URL uniquement) | -Phase 2 ajoutera : `checklist`, `image`, et le format de `content` passera de -`string` à `InlineNode[]` pour supporter le formatting inline (gras, italique, -couleur, lien). Phase 3 : `table`. +`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` | — | `` | +| `italic` | — | `` | +| `underline` | — | `` | +| `strike` | — | `` (monospace, fond gris) |
+| `link` | `href: string` | `` (target="_blank") |
+| `color` | `color: 'blue' \| 'green' \| 'amber' \| 'red'` | couleur du texte |
+| `highlight` | `color: 'blue' \| 'green' \| 'amber' \| 'red'` | surlignage de fond |
+
+Les couleurs sont des **clés de palette** (pas de hex libre) — résolues vers
+les classes Tailwind du DESIGN system. Voir `inline/types.js:INLINE_COLORS`.
+
+Le contenu vide est `[]` (jamais `[{type:'text', text:''}]`).
+
+### Helpers exportés
+
+```js
+import {
+ inlineLength, inlineToPlainText, inlineFromText,
+ sliceInline, concatInline, applyMark, toggleMark,
+ marksAtOffset, marksInRange, INLINE_COLORS,
+} 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
@@ -47,18 +94,37 @@ couleur, lien). Phase 3 : `table`.
## Interactions clavier
-- `/` → ouvre le menu de commandes (filtrable au clavier, ↑ ↓ Entrée pour valider, Échap pour fermer)
+- `/` → 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 (popover de la palette)
+- **◐** — surlignage (popover)
+- **🔗** — lien (popover avec input URL ; ✕ pour retirer)
+
+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.
+
## Sélection multi-blocs
Deux façons d'entrer en mode sélection multi-blocs :
@@ -70,7 +136,7 @@ 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 le texte concaténé
+- `Ctrl/Cmd + C` / `Ctrl/Cmd + X` → copie/coupe le texte concaténé (texte plat)
- 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
@@ -109,24 +175,31 @@ registerBlock({
Le nouveau type apparaît automatiquement dans le slash menu de tout
` ` rendu après l'enregistrement. Pour les blocs **texte**, on
-fournit à la place `isText: true`, `textTag`, `textClassName`, et optionnellement
-`renderPrefix({ block, index, numberedIndex })` pour un préfixe (puce, numéro).
+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
+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 dans un contentEditable
+utils/caret.js gestion du caret (multi-Text-nodes)
```
-## Limitations connues (Phase 1)
+## Limitations connues
-- Inline formatting (gras, italique, couleur, lien) **pas encore** : tout est texte brut. Phase 2.
- Pas d'imbrication de listes.
-- Paste : seul le texte brut est conservé (sanitize HTML).
+- Paste : seul le texte brut est conservé (sanitize HTML). Le formatage
+ inline copié depuis l'extérieur n'est pas préservé.
+- Image : URL uniquement, pas d'upload de fichier (Phase 2). La caption est
+ une string plate (pas de formatage inline pour l'instant).
- Tables : Phase 3.
diff --git a/src/shared/components/BlockEditor/SlashMenu.client.js b/src/shared/components/BlockEditor/SlashMenu.client.js
index 1b46348..6045961 100644
--- a/src/shared/components/BlockEditor/SlashMenu.client.js
+++ b/src/shared/components/BlockEditor/SlashMenu.client.js
@@ -16,9 +16,11 @@ const SHORTCUT_HINT = {
heading_6: '######',
bullet_item: '-',
numbered_item: '1.',
+ checklist: '[]',
quote: '>',
code: '```',
divider: '---',
+ image: '',
};
import { listBlocks } from './blockRegistry.js';
diff --git a/src/shared/components/BlockEditor/blockTypes/BulletList.js b/src/shared/components/BlockEditor/blockTypes/BulletList.js
index 1af25ca..a276e2f 100644
--- a/src/shared/components/BlockEditor/blockTypes/BulletList.js
+++ b/src/shared/components/BlockEditor/blockTypes/BulletList.js
@@ -19,7 +19,7 @@ const BulletItem = {
);
},
create(init = {}) {
- return { id: newBlockId(), type: 'bullet_item', content: '', ...init };
+ return { id: newBlockId(), type: 'bullet_item', content: [], ...init };
},
};
diff --git a/src/shared/components/BlockEditor/blockTypes/Checklist.js b/src/shared/components/BlockEditor/blockTypes/Checklist.js
new file mode 100644
index 0000000..fad76f6
--- /dev/null
+++ b/src/shared/components/BlockEditor/blockTypes/Checklist.js
@@ -0,0 +1,51 @@
+import { Tick02Icon } from '@zen/core/shared/icons';
+import { newBlockId } from '../utils/ids.js';
+
+// Préfixe : case à cocher cliquable. `onPatch({ checked })` mute l'état du
+// bloc (consommé par BlockEditor → handleBlockPatch).
+function Checkbox({ block, onPatch, disabled }) {
+ function toggle(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (disabled) return;
+ onPatch?.({ checked: !block.checked });
+ }
+ return (
+
+ );
+}
+
+const Checklist = {
+ type: 'checklist',
+ label: 'Case à cocher',
+ icon: '☐',
+ keywords: ['checklist', 'todo', 'tache', 'tâche', 'case', 'cocher', 'check'],
+ shortcut: '[] ',
+ isText: true,
+ textTag: 'div',
+ textClassName: 'text-base leading-relaxed text-neutral-900 dark:text-white',
+ placeholder: 'À faire…',
+ renderPrefix({ block, onPatch, disabled }) {
+ return ;
+ },
+ create(init = {}) {
+ return { id: newBlockId(), type: 'checklist', content: [], checked: false, ...init };
+ },
+};
+
+export default Checklist;
diff --git a/src/shared/components/BlockEditor/blockTypes/Code.js b/src/shared/components/BlockEditor/blockTypes/Code.js
index dc2887c..576a2c3 100644
--- a/src/shared/components/BlockEditor/blockTypes/Code.js
+++ b/src/shared/components/BlockEditor/blockTypes/Code.js
@@ -13,7 +13,7 @@ const Code = {
'block w-full font-mono text-sm whitespace-pre-wrap break-words rounded-lg bg-neutral-100 dark:bg-neutral-800/80 px-4 py-3 text-neutral-900 dark:text-neutral-100',
placeholder: 'Code…',
create(init = {}) {
- return { id: newBlockId(), type: 'code', content: '', ...init };
+ return { id: newBlockId(), type: 'code', content: [], ...init };
},
};
diff --git a/src/shared/components/BlockEditor/blockTypes/Heading.js b/src/shared/components/BlockEditor/blockTypes/Heading.js
index 41f971d..cbd840c 100644
--- a/src/shared/components/BlockEditor/blockTypes/Heading.js
+++ b/src/shared/components/BlockEditor/blockTypes/Heading.js
@@ -21,7 +21,7 @@ function makeHeading(level) {
placeholder: `Titre ${level}`,
shortcut: `${'#'.repeat(level)} `,
create(init = {}) {
- return { id: newBlockId(), type: `heading_${level}`, content: '', ...init };
+ return { id: newBlockId(), type: `heading_${level}`, content: [], ...init };
},
};
}
diff --git a/src/shared/components/BlockEditor/blockTypes/Image.client.js b/src/shared/components/BlockEditor/blockTypes/Image.client.js
new file mode 100644
index 0000000..063ade6
--- /dev/null
+++ b/src/shared/components/BlockEditor/blockTypes/Image.client.js
@@ -0,0 +1,121 @@
+'use client';
+
+import React, { useEffect, useRef, useState } from 'react';
+import { Image01Icon } from '@zen/core/shared/icons';
+import { newBlockId } from '../utils/ids.js';
+
+// Bloc image. Phase 2 : URL uniquement (pas d'upload). État vide = formulaire
+// d'insertion d'URL. État rempli = image rendue + caption optionnelle.
+
+function ImageBlock({ block, onChange, disabled }) {
+ const [url, setUrl] = useState(block.src ?? '');
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (!block.src && !disabled) {
+ // Au montage initial du bloc vide, on focus l'input automatiquement.
+ inputRef.current?.focus();
+ }
+ }, [block.src, disabled]);
+
+ function submit(e) {
+ e.preventDefault();
+ if (!url.trim()) return;
+ onChange?.({ src: url.trim() });
+ }
+
+ function handleAltChange(e) {
+ onChange?.({ alt: e.target.value });
+ }
+
+ function handleCaptionChange(e) {
+ onChange?.({ caption: e.target.value });
+ }
+
+ function reset() {
+ setUrl('');
+ onChange?.({ src: '', alt: '', caption: '' });
+ }
+
+ if (!block.src) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+ {!disabled && (
+
+ )}
+
+
+ {!disabled && (
+
+ )}
+
+
+ );
+}
+
+const Image = {
+ type: 'image',
+ label: 'Image',
+ icon: '🖼',
+ keywords: ['image', 'photo', 'picture', 'img'],
+ isText: false,
+ create(init = {}) {
+ return { id: newBlockId(), type: 'image', src: '', alt: '', caption: '', ...init };
+ },
+ Component: ImageBlock,
+};
+
+export default Image;
diff --git a/src/shared/components/BlockEditor/blockTypes/NumberedList.js b/src/shared/components/BlockEditor/blockTypes/NumberedList.js
index 96785b6..ee67cf0 100644
--- a/src/shared/components/BlockEditor/blockTypes/NumberedList.js
+++ b/src/shared/components/BlockEditor/blockTypes/NumberedList.js
@@ -22,7 +22,7 @@ const NumberedItem = {
);
},
create(init = {}) {
- return { id: newBlockId(), type: 'numbered_item', content: '', ...init };
+ return { id: newBlockId(), type: 'numbered_item', content: [], ...init };
},
};
diff --git a/src/shared/components/BlockEditor/blockTypes/Paragraph.js b/src/shared/components/BlockEditor/blockTypes/Paragraph.js
index 0230207..88581f1 100644
--- a/src/shared/components/BlockEditor/blockTypes/Paragraph.js
+++ b/src/shared/components/BlockEditor/blockTypes/Paragraph.js
@@ -10,7 +10,7 @@ const Paragraph = {
textClassName: 'text-base leading-relaxed text-neutral-900 dark:text-white',
placeholder: "Tapez '/' pour les commandes…",
create(init = {}) {
- return { id: newBlockId(), type: 'paragraph', content: '', ...init };
+ return { id: newBlockId(), type: 'paragraph', content: [], ...init };
},
};
diff --git a/src/shared/components/BlockEditor/blockTypes/Quote.js b/src/shared/components/BlockEditor/blockTypes/Quote.js
index 5f10176..99be744 100644
--- a/src/shared/components/BlockEditor/blockTypes/Quote.js
+++ b/src/shared/components/BlockEditor/blockTypes/Quote.js
@@ -12,7 +12,7 @@ const Quote = {
'text-base leading-relaxed italic text-neutral-700 dark:text-neutral-300 border-l-4 border-neutral-300 dark:border-neutral-700 pl-4 py-1',
placeholder: 'Citation…',
create(init = {}) {
- return { id: newBlockId(), type: 'quote', content: '', ...init };
+ return { id: newBlockId(), type: 'quote', content: [], ...init };
},
};
diff --git a/src/shared/components/BlockEditor/blockTypes/index.js b/src/shared/components/BlockEditor/blockTypes/index.js
index 9c53f04..7c28fac 100644
--- a/src/shared/components/BlockEditor/blockTypes/index.js
+++ b/src/shared/components/BlockEditor/blockTypes/index.js
@@ -9,6 +9,8 @@ import NumberedItem from './NumberedList.js';
import Quote from './Quote.js';
import Code from './Code.js';
import Divider from './Divider.js';
+import Checklist from './Checklist.js';
+import ImageBlock from './Image.client.js';
let registered = false;
@@ -19,7 +21,9 @@ export function registerBuiltInBlocks() {
HeadingList.forEach(registerBlock);
registerBlock(BulletItem);
registerBlock(NumberedItem);
+ registerBlock(Checklist);
registerBlock(Quote);
registerBlock(Code);
registerBlock(Divider);
+ registerBlock(ImageBlock);
}
diff --git a/src/shared/components/BlockEditor/index.js b/src/shared/components/BlockEditor/index.js
index 9485588..d323d72 100644
--- a/src/shared/components/BlockEditor/index.js
+++ b/src/shared/components/BlockEditor/index.js
@@ -19,3 +19,19 @@ export {
DEFAULT_BLOCK_TYPE,
} from './blockRegistry.js';
export { newBlockId } from './utils/ids.js';
+export {
+ INLINE_COLORS,
+ INLINE_COLOR_KEYS,
+ inlineLength,
+ inlineToPlainText,
+ inlineFromText,
+ sliceInline,
+ concatInline,
+ applyMark,
+ removeMark,
+ toggleMark,
+ marksAtOffset,
+ marksInRange,
+ normalize as normalizeInline,
+} from './inline/types.js';
+export { inlineToDom, domToInline } from './inline/serialize.js';
diff --git a/src/shared/components/BlockEditor/inline/Toolbar.client.js b/src/shared/components/BlockEditor/inline/Toolbar.client.js
new file mode 100644
index 0000000..34027ef
--- /dev/null
+++ b/src/shared/components/BlockEditor/inline/Toolbar.client.js
@@ -0,0 +1,210 @@
+'use client';
+
+import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { INLINE_COLORS, INLINE_COLOR_KEYS, markKey } from './types.js';
+
+// Toolbar flottant de formatage. Affiché tant qu'une sélection non-vide
+// existe dans un bloc. Ancré au-dessus du rect de sélection ; flip en
+// dessous si pas assez de place.
+//
+// Ne contient pas d'état métier — tous les changements remontent via
+// `onToggleMark(mark)`. Le parent recalcule `activeMarks` à chaque rendu.
+
+const TOOLBAR_HEIGHT = 36;
+const TOOLBAR_GAP = 8;
+const VIEWPORT_MARGIN = 8;
+
+const SIMPLE_BUTTONS = [
+ { type: 'bold', label: 'B', title: 'Gras (Ctrl+B)', className: 'font-bold' },
+ { type: 'italic', label: 'I', title: 'Italique (Ctrl+I)', className: 'italic' },
+ { type: 'underline', label: 'U', title: 'Soulignement (Ctrl+U)', className: 'underline' },
+ { type: 'strike', label: 'S', title: 'Barré', className: 'line-through' },
+ { type: 'code', label: '>', title: 'Code (Ctrl+E)', className: 'font-mono text-[11px]' },
+];
+
+export default function InlineToolbar({ rect, activeMarks, onToggleMark }) {
+ const ref = useRef(null);
+ const [pos, setPos] = useState({ top: 0, left: 0, flipped: false });
+ const [popover, setPopover] = useState(null); // 'color' | 'highlight' | 'link' | null
+ const [linkUrl, setLinkUrl] = useState('');
+
+ useLayoutEffect(() => {
+ if (!rect || typeof window === 'undefined') return;
+ const width = ref.current?.offsetWidth ?? 280;
+ const height = ref.current?.offsetHeight ?? TOOLBAR_HEIGHT;
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const spaceAbove = rect.top - VIEWPORT_MARGIN;
+ const flipBelow = spaceAbove < height + TOOLBAR_GAP;
+ let top = flipBelow
+ ? rect.bottom + TOOLBAR_GAP
+ : rect.top - height - TOOLBAR_GAP;
+ let left = rect.left + rect.width / 2 - width / 2;
+ if (left + width + VIEWPORT_MARGIN > vw) left = vw - width - VIEWPORT_MARGIN;
+ if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;
+ if (top < VIEWPORT_MARGIN) top = VIEWPORT_MARGIN;
+ if (top + height + VIEWPORT_MARGIN > vh) top = vh - height - VIEWPORT_MARGIN;
+ setPos({ top, left, flipped: flipBelow });
+ }, [rect]);
+
+ // Ferme un popover ouvert quand la sélection change (rect change).
+ useEffect(() => { setPopover(null); }, [rect?.top, rect?.left]);
+
+ function isActive(type, payloadKey) {
+ if (!Array.isArray(activeMarks)) return false;
+ if (payloadKey) return activeMarks.some(m => markKey(m) === payloadKey);
+ return activeMarks.some(m => m.type === type);
+ }
+
+ function handleSimple(type) {
+ onToggleMark?.({ type });
+ }
+
+ function handleColor(color) {
+ onToggleMark?.({ type: 'color', color });
+ setPopover(null);
+ }
+
+ function handleHighlight(color) {
+ onToggleMark?.({ type: 'highlight', color });
+ setPopover(null);
+ }
+
+ function handleLinkSubmit(e) {
+ e.preventDefault();
+ if (!linkUrl) return;
+ onToggleMark?.({ type: 'link', href: linkUrl });
+ setLinkUrl('');
+ setPopover(null);
+ }
+
+ function handleLinkRemove() {
+ // Trouver le href actif pour reproduire la même mark (toggle off).
+ const link = activeMarks.find(m => m.type === 'link');
+ if (link) onToggleMark?.({ type: 'link', href: link.href });
+ setPopover(null);
+ }
+
+ function openLinkPopover() {
+ const link = activeMarks.find(m => m.type === 'link');
+ setLinkUrl(link?.href ?? '');
+ setPopover(p => (p === 'link' ? null : 'link'));
+ }
+
+ return (
+ e.preventDefault()}
+ >
+ {SIMPLE_BUTTONS.map(btn => (
+
+ ))}
+
+
+
+
+
+
+
+ {popover === 'color' && (
+ m.type === 'color')?.color}
+ onPick={handleColor}
+ />
+ )}
+ {popover === 'highlight' && (
+ m.type === 'highlight')?.color}
+ onPick={handleHighlight}
+ />
+ )}
+ {popover === 'link' && (
+
+ )}
+
+ );
+}
+
+function ColorGrid({ mode, activeKey, onPick }) {
+ return (
+
+ {INLINE_COLOR_KEYS.map(key => {
+ const palette = INLINE_COLORS[key];
+ const tw = mode === 'text' ? palette.text : palette.highlight;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/shared/components/BlockEditor/inline/serialize.js b/src/shared/components/BlockEditor/inline/serialize.js
new file mode 100644
index 0000000..44e59ba
--- /dev/null
+++ b/src/shared/components/BlockEditor/inline/serialize.js
@@ -0,0 +1,166 @@
+// Sérialisation `InlineNode[]` ↔ DOM.
+//
+// Le contentEditable de chaque bloc texte contient un sous-arbre HTML que
+// l'utilisateur édite. À chaque frappe, on lit l'arbre via `domToInline`
+// pour reconstruire les nœuds. À chaque changement externe (undo, transform,
+// toolbar), on réécrit l'arbre via `inlineToDom`.
+//
+// Ordre canonique des wrappers (extérieur → intérieur) :
+// > > > > >
+// > > > #text
+//
+// Cet ordre est important pour que la sérialisation aller-retour soit
+// stable : `inlineToDom(domToInline(x))` produit le même HTML que `x`.
+
+import { INLINE_COLORS, normalize } from './types.js';
+
+const SIMPLE_TAGS = {
+ bold: 'STRONG',
+ italic: 'EM',
+ underline: 'U',
+ strike: 'S',
+ code: 'CODE',
+};
+
+const TAG_TO_MARK = {
+ STRONG: 'bold',
+ B: 'bold',
+ EM: 'italic',
+ I: 'italic',
+ U: 'underline',
+ S: 'strike',
+ STRIKE: 'strike',
+ DEL: 'strike',
+ CODE: 'code',
+};
+
+function findMark(marks, type) {
+ return marks?.find(m => m.type === type);
+}
+
+// Construit un fragment DOM. Reçoit un Document optionnel (utile en SSR /
+// tests) ; sinon utilise `document` global.
+export function inlineToDom(nodes, doc) {
+ const d = doc || (typeof document !== 'undefined' ? document : null);
+ if (!d) throw new Error('inlineToDom: document requis');
+ const fragment = d.createDocumentFragment();
+ if (!Array.isArray(nodes) || nodes.length === 0) return fragment;
+ for (const node of nodes) {
+ fragment.appendChild(buildNode(d, node));
+ }
+ return fragment;
+}
+
+function buildNode(d, node) {
+ let el = d.createTextNode(node.text ?? '');
+ const marks = node.marks || [];
+
+ // Ordre intérieur → extérieur (on enveloppe progressivement).
+ // 1. Marks simples (code, strike, underline, italic, bold) — plus on est
+ // interne, plus on est dans la cascade visuelle « précise ».
+ if (findMark(marks, 'code')) el = wrap(d, el, 'code', { className: 'rounded px-1 py-0.5 font-mono text-[0.9em] bg-neutral-100 dark:bg-neutral-800/80' });
+ if (findMark(marks, 'strike')) el = wrap(d, el, 's');
+ if (findMark(marks, 'underline')) el = wrap(d, el, 'u');
+ if (findMark(marks, 'italic')) el = wrap(d, el, 'em');
+ if (findMark(marks, 'bold')) el = wrap(d, el, 'strong');
+
+ // 2. Couleur du texte.
+ const color = findMark(marks, 'color');
+ if (color) {
+ const tw = INLINE_COLORS[color.color]?.text;
+ el = wrap(d, el, 'span', {
+ className: tw || '',
+ attrs: { 'data-color': color.color },
+ });
+ }
+
+ // 3. Surlignage.
+ const highlight = findMark(marks, 'highlight');
+ if (highlight) {
+ const tw = INLINE_COLORS[highlight.color]?.highlight;
+ el = wrap(d, el, 'span', {
+ className: `rounded px-0.5 ${tw || ''}`,
+ attrs: { 'data-highlight': highlight.color },
+ });
+ }
+
+ // 4. Lien — toujours à l'extérieur.
+ const link = findMark(marks, 'link');
+ if (link) {
+ el = wrap(d, el, 'a', {
+ className: 'text-blue-600 dark:text-blue-400 underline underline-offset-2',
+ attrs: {
+ href: link.href,
+ rel: 'noopener noreferrer',
+ target: '_blank',
+ },
+ });
+ }
+
+ return el;
+}
+
+function wrap(d, child, tagName, opts = {}) {
+ const el = d.createElement(tagName);
+ if (opts.className) el.className = opts.className;
+ if (opts.attrs) {
+ for (const [k, v] of Object.entries(opts.attrs)) el.setAttribute(k, v);
+ }
+ el.appendChild(child);
+ return el;
+}
+
+// Walk DOM → InlineNode[]. Accumule les marks dans une pile au fur et à
+// mesure qu'on descend. Émet un nœud par run de texte.
+export function domToInline(root) {
+ if (!root) return [];
+ const out = [];
+ walk(root, [], out);
+ return normalize(out);
+}
+
+function walk(node, marks, out) {
+ if (node.nodeType === 3 /* TEXT_NODE */) {
+ if (node.nodeValue) {
+ out.push({
+ type: 'text',
+ text: node.nodeValue,
+ ...(marks.length ? { marks: marks.map(m => ({ ...m })) } : {}),
+ });
+ }
+ return;
+ }
+ if (node.nodeType !== 1 /* ELEMENT_NODE */) return;
+
+ //
: émet un saut de ligne explicite. Notre modèle n'a pas de bloc
+ // multi-lignes (Enter crée un nouveau bloc), mais Chrome injecte parfois
+ // un
trailing dans un contentEditable vide — on l'ignore.
+ if (node.tagName === 'BR') {
+ if (node.nextSibling || node.previousSibling) {
+ out.push({ type: 'text', text: '\n', ...(marks.length ? { marks: marks.map(m => ({ ...m })) } : {}) });
+ }
+ return;
+ }
+
+ const added = [];
+ const tag = node.tagName;
+ const simple = TAG_TO_MARK[tag];
+ if (simple) added.push({ type: simple });
+
+ if (tag === 'A') {
+ const href = node.getAttribute('href') || '';
+ if (href) added.push({ type: 'link', href });
+ }
+
+ if (tag === 'SPAN') {
+ const color = node.getAttribute('data-color');
+ const highlight = node.getAttribute('data-highlight');
+ if (color) added.push({ type: 'color', color });
+ if (highlight) added.push({ type: 'highlight', color: highlight });
+ }
+
+ const nextMarks = added.length ? [...marks, ...added] : marks;
+ for (const child of node.childNodes) {
+ walk(child, nextMarks, out);
+ }
+}
diff --git a/src/shared/components/BlockEditor/inline/types.js b/src/shared/components/BlockEditor/inline/types.js
new file mode 100644
index 0000000..daf3f47
--- /dev/null
+++ b/src/shared/components/BlockEditor/inline/types.js
@@ -0,0 +1,290 @@
+// Format `InlineNode[]` : représentation du contenu inline d'un bloc texte.
+// Tableau plat de nœuds `text`, chacun pouvant porter des marks (gras,
+// italique, lien, couleur…). Un seul type de nœud — pas d'arbre imbriqué.
+//
+// Schéma :
+// InlineNode = { type: 'text', text: string, marks?: Mark[] }
+// Mark =
+// | { type: 'bold' }
+// | { type: 'italic' }
+// | { type: 'underline' }
+// | { type: 'strike' }
+// | { type: 'code' }
+// | { type: 'color', color: string } // clé palette
+// | { type: 'highlight', color: string } // clé palette
+// | { type: 'link', href: string }
+//
+// Le contenu vide est `[]` (jamais `[{type:'text', text:''}]`).
+
+// Palette des couleurs sémantiques du DESIGN system (cf. docs/DESIGN.md).
+// Bleu / vert / ambre / rouge — utilisées avec parcimonie. Les classes
+// Tailwind sont appliquées au rendu via `inlineToDom`.
+export const INLINE_COLORS = {
+ blue: { text: 'text-blue-600 dark:text-blue-400', highlight: 'bg-blue-100 dark:bg-blue-900/40' },
+ green: { text: 'text-green-600 dark:text-green-400', highlight: 'bg-green-100 dark:bg-green-900/40' },
+ amber: { text: 'text-amber-600 dark:text-amber-400', highlight: 'bg-amber-100 dark:bg-amber-900/40' },
+ red: { text: 'text-red-600 dark:text-red-400', highlight: 'bg-red-100 dark:bg-red-900/40' },
+};
+
+export const INLINE_COLOR_KEYS = Object.keys(INLINE_COLORS);
+
+const SIMPLE_MARK_TYPES = ['bold', 'italic', 'underline', 'strike', 'code'];
+
+function marksEqual(a, b) {
+ if (a === b) return true;
+ if (!a && !b) return true;
+ if (!a || !b) return (a?.length ?? 0) === 0 && (b?.length ?? 0) === 0;
+ if (a.length !== b.length) return false;
+ // Comparaison ensemble (ordre indifférent), via sérialisation déterministe.
+ const ka = a.map(markKey).sort();
+ const kb = b.map(markKey).sort();
+ for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return false;
+ return true;
+}
+
+export function markKey(mark) {
+ switch (mark.type) {
+ case 'color':
+ case 'highlight':
+ return `${mark.type}:${mark.color}`;
+ case 'link':
+ return `link:${mark.href}`;
+ default:
+ return mark.type;
+ }
+}
+
+function cloneMarks(marks) {
+ return marks ? marks.map(m => ({ ...m })) : undefined;
+}
+
+function normalizeMarks(marks) {
+ if (!marks || marks.length === 0) return undefined;
+ // Déduplique : pour les marks paramétrées (color/highlight/link), garder la
+ // dernière occurrence ; pour les simples, garder une seule.
+ const byBucket = new Map();
+ for (const m of marks) {
+ const bucket = m.type === 'color' || m.type === 'highlight' || m.type === 'link' ? m.type : m.type;
+ byBucket.set(bucket, m);
+ }
+ const out = Array.from(byBucket.values()).map(m => ({ ...m }));
+ return out.length === 0 ? undefined : out;
+}
+
+function makeNode(text, marks) {
+ const node = { type: 'text', text };
+ const norm = normalizeMarks(marks);
+ if (norm) node.marks = norm;
+ return node;
+}
+
+// Longueur texte totale.
+export function inlineLength(nodes) {
+ if (!Array.isArray(nodes)) return 0;
+ let n = 0;
+ for (const node of nodes) n += node.text?.length ?? 0;
+ return n;
+}
+
+// Texte concaténé (pour copier/coller, comparaisons rapides).
+export function inlineToPlainText(nodes) {
+ if (!Array.isArray(nodes)) return '';
+ let out = '';
+ for (const node of nodes) out += node.text ?? '';
+ return out;
+}
+
+// Construit un tableau `InlineNode[]` à partir d'une string brute.
+// `[]` si la string est vide — pas de nœud vide.
+export function inlineFromText(text) {
+ if (!text) return [];
+ return [{ type: 'text', text }];
+}
+
+// Sous-tableau couvrant l'intervalle [start, end[. Découpe les nœuds aux
+// frontières si besoin. Préserve les marks.
+export function sliceInline(nodes, start, end) {
+ if (!Array.isArray(nodes)) return [];
+ const total = inlineLength(nodes);
+ const a = Math.max(0, Math.min(start, total));
+ const b = Math.max(a, Math.min(end ?? total, total));
+ if (a === b) return [];
+ const out = [];
+ let pos = 0;
+ for (const node of nodes) {
+ const len = node.text?.length ?? 0;
+ const nodeStart = pos;
+ const nodeEnd = pos + len;
+ pos = nodeEnd;
+ if (nodeEnd <= a) continue;
+ if (nodeStart >= b) break;
+ const localStart = Math.max(0, a - nodeStart);
+ const localEnd = Math.min(len, b - nodeStart);
+ if (localEnd <= localStart) continue;
+ out.push(makeNode(node.text.slice(localStart, localEnd), cloneMarks(node.marks)));
+ }
+ return normalize(out);
+}
+
+// Concatène deux tableaux en fusionnant le dernier nœud de `a` et le premier
+// de `b` s'ils ont les mêmes marks.
+export function concatInline(a, b) {
+ if (!Array.isArray(a) || a.length === 0) return Array.isArray(b) ? normalize(b) : [];
+ if (!Array.isArray(b) || b.length === 0) return normalize(a);
+ return normalize([...a.map(n => ({ ...n, marks: cloneMarks(n.marks) })),
+ ...b.map(n => ({ ...n, marks: cloneMarks(n.marks) }))]);
+}
+
+// Fusionne les nœuds adjacents identiques et supprime les nœuds vides.
+export function normalize(nodes) {
+ if (!Array.isArray(nodes)) return [];
+ const out = [];
+ for (const node of nodes) {
+ if (!node.text) continue;
+ const last = out[out.length - 1];
+ if (last && marksEqual(last.marks, node.marks)) {
+ last.text += node.text;
+ } else {
+ out.push({ type: 'text', text: node.text, ...(node.marks ? { marks: cloneMarks(node.marks) } : {}) });
+ }
+ }
+ return out;
+}
+
+// Retourne les marks actives à un offset donné (utile pour le toolbar).
+// À une frontière entre deux nœuds, on prend les marks du nœud à droite,
+// sauf en fin de tableau où on prend le nœud de gauche.
+export function marksAtOffset(nodes, offset) {
+ if (!Array.isArray(nodes) || nodes.length === 0) return [];
+ let pos = 0;
+ for (const node of nodes) {
+ const len = node.text?.length ?? 0;
+ if (offset < pos + len) return node.marks ? node.marks.map(m => ({ ...m })) : [];
+ pos += len;
+ }
+ // offset au-delà du dernier nœud → marks du dernier nœud.
+ const last = nodes[nodes.length - 1];
+ return last.marks ? last.marks.map(m => ({ ...m })) : [];
+}
+
+// Marks communes à toute la plage [start, end[. Si la plage est vide,
+// retourne les marks à l'offset `start`. Utile pour afficher l'état actif
+// du toolbar.
+export function marksInRange(nodes, start, end) {
+ if (start >= end) return marksAtOffset(nodes, start);
+ if (!Array.isArray(nodes) || nodes.length === 0) return [];
+ let common = null;
+ let pos = 0;
+ for (const node of nodes) {
+ const len = node.text?.length ?? 0;
+ const nodeStart = pos;
+ const nodeEnd = pos + len;
+ pos = nodeEnd;
+ if (nodeEnd <= start) continue;
+ if (nodeStart >= end) break;
+ const ks = (node.marks || []).map(markKey);
+ if (common === null) {
+ common = new Set(ks);
+ } else {
+ for (const k of Array.from(common)) if (!ks.includes(k)) common.delete(k);
+ }
+ if (common.size === 0) return [];
+ }
+ if (!common) return [];
+ // Reconstruit les objets mark depuis les nœuds couverts.
+ const result = [];
+ pos = 0;
+ for (const node of nodes) {
+ const len = node.text?.length ?? 0;
+ const nodeStart = pos;
+ const nodeEnd = pos + len;
+ pos = nodeEnd;
+ if (nodeEnd <= start) continue;
+ if (nodeStart >= end) break;
+ for (const m of node.marks || []) {
+ if (common.has(markKey(m)) && !result.some(r => markKey(r) === markKey(m))) {
+ result.push({ ...m });
+ }
+ }
+ }
+ return result;
+}
+
+function addMarkToNode(node, mark) {
+ const existing = node.marks || [];
+ // Pour les marks paramétrées, on remplace (color/highlight/link sont
+ // exclusives entre elles dans la même catégorie).
+ const filtered = existing.filter(m => m.type !== mark.type);
+ return makeNode(node.text, [...filtered, { ...mark }]);
+}
+
+function removeMarkFromNode(node, type) {
+ const existing = node.marks || [];
+ const filtered = existing.filter(m => m.type !== type);
+ return makeNode(node.text, filtered);
+}
+
+function mapRange(nodes, start, end, fn) {
+ if (start >= end || !Array.isArray(nodes)) return Array.isArray(nodes) ? normalize(nodes) : [];
+ const out = [];
+ let pos = 0;
+ for (const node of nodes) {
+ const len = node.text?.length ?? 0;
+ const nodeStart = pos;
+ const nodeEnd = pos + len;
+ pos = nodeEnd;
+ if (len === 0) continue;
+ if (nodeEnd <= start || nodeStart >= end) {
+ out.push(makeNode(node.text, cloneMarks(node.marks)));
+ continue;
+ }
+ const localStart = Math.max(0, start - nodeStart);
+ const localEnd = Math.min(len, end - nodeStart);
+ if (localStart > 0) {
+ out.push(makeNode(node.text.slice(0, localStart), cloneMarks(node.marks)));
+ }
+ if (localEnd > localStart) {
+ const middle = makeNode(node.text.slice(localStart, localEnd), cloneMarks(node.marks));
+ out.push(fn(middle));
+ }
+ if (localEnd < len) {
+ out.push(makeNode(node.text.slice(localEnd), cloneMarks(node.marks)));
+ }
+ }
+ return normalize(out);
+}
+
+export function applyMark(nodes, start, end, mark) {
+ return mapRange(nodes, start, end, n => addMarkToNode(n, mark));
+}
+
+export function removeMark(nodes, start, end, type) {
+ return mapRange(nodes, start, end, n => removeMarkFromNode(n, type));
+}
+
+// Toggle : si toute la plage porte déjà la mark (au sens markKey strict),
+// on la retire ; sinon on l'applique partout.
+export function toggleMark(nodes, start, end, mark) {
+ const active = marksInRange(nodes, start, end).some(m => markKey(m) === markKey(mark));
+ if (active) {
+ if (mark.type === 'color' || mark.type === 'highlight' || mark.type === 'link') {
+ return removeMark(nodes, start, end, mark.type);
+ }
+ return removeMark(nodes, start, end, mark.type);
+ }
+ return applyMark(nodes, start, end, mark);
+}
+
+// Insère du texte brut à un offset, héritant des marks de l'environnement.
+// Utilisé après une transformation pour réinjecter du texte sans perdre le
+// contexte. Phase 2 ne s'en sert pas en édition (la frappe passe par le
+// DOM puis `domToInline`), mais c'est utile pour les helpers programmatiques.
+export function insertText(nodes, offset, text) {
+ if (!text) return normalize(nodes ?? []);
+ const before = sliceInline(nodes, 0, offset);
+ const after = sliceInline(nodes, offset, inlineLength(nodes));
+ // Hérite des marks du nœud à gauche (continuité de la frappe).
+ const marks = marksAtOffset(nodes, Math.max(0, offset - 1));
+ const inserted = [makeNode(text, marks)];
+ return concatInline(concatInline(before, inserted), after);
+}
diff --git a/src/shared/components/BlockEditor/utils/caret.js b/src/shared/components/BlockEditor/utils/caret.js
index ce7a12c..0405d2f 100644
--- a/src/shared/components/BlockEditor/utils/caret.js
+++ b/src/shared/components/BlockEditor/utils/caret.js
@@ -1,5 +1,7 @@
-// Helpers de gestion du caret pour les contentEditable mono-bloc.
-// On reste sur du texte brut au MVP : un seul Text node enfant (ou aucun).
+// Helpers de gestion du caret pour les contentEditable.
+// Depuis Phase 2, l'arbre interne peut contenir plusieurs Text nodes
+// emballés dans des wrappers (, , , ...).
+// Les fonctions ci-dessous calculent / posent un offset texte global.
export function getCaretOffset(el) {
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
@@ -12,22 +14,68 @@ export function getCaretOffset(el) {
return pre.toString().length;
}
+export function getCaretRange(el) {
+ const sel = typeof window !== 'undefined' ? window.getSelection() : null;
+ if (!sel || sel.rangeCount === 0) return null;
+ const range = sel.getRangeAt(0);
+ if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return null;
+ const startPre = range.cloneRange();
+ startPre.selectNodeContents(el);
+ startPre.setEnd(range.startContainer, range.startOffset);
+ const endPre = range.cloneRange();
+ endPre.selectNodeContents(el);
+ endPre.setEnd(range.endContainer, range.endOffset);
+ return { start: startPre.toString().length, end: endPre.toString().length };
+}
+
+// Trouve le Text node + offset local correspondant à un offset global.
+// Si l'arbre est vide, retourne `{ node: el, offset: 0 }` pour positionner
+// le caret directement dans l'élément racine.
+function locateOffset(el, offset) {
+ if (!el) return null;
+ const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT);
+ let consumed = 0;
+ let last = null;
+ let textNode = walker.nextNode();
+ while (textNode) {
+ const len = textNode.nodeValue?.length ?? 0;
+ if (offset <= consumed + len) {
+ return { node: textNode, offset: Math.max(0, offset - consumed) };
+ }
+ consumed += len;
+ last = textNode;
+ textNode = walker.nextNode();
+ }
+ if (last) return { node: last, offset: last.nodeValue?.length ?? 0 };
+ return { node: el, offset: 0 };
+}
+
export function setCaretOffset(el, offset) {
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel) return;
+ const target = locateOffset(el, Math.max(0, offset || 0));
+ if (!target) return;
const range = document.createRange();
- const text = el.firstChild;
- if (!text) {
- range.setStart(el, 0);
- range.collapse(true);
- } else {
- const max = text.textContent?.length ?? 0;
- const pos = Math.max(0, Math.min(offset, max));
- range.setStart(text, pos);
- range.collapse(true);
- }
+ range.setStart(target.node, target.offset);
+ range.collapse(true);
+ sel.removeAllRanges();
+ sel.addRange(range);
+}
+
+// Pose une sélection couvrant [start, end[ dans l'élément.
+export function setCaretRange(el, start, end) {
+ if (!el) return;
+ el.focus();
+ const sel = window.getSelection();
+ if (!sel) return;
+ const a = locateOffset(el, Math.max(0, start || 0));
+ const b = locateOffset(el, Math.max(0, end || 0));
+ if (!a || !b) return;
+ const range = document.createRange();
+ range.setStart(a.node, a.offset);
+ range.setEnd(b.node, b.offset);
sel.removeAllRanges();
sel.addRange(range);
}