feat(BlockEditor): add inline formatting with rich content model

- migrate block content from plain strings to InlineNode[] structure
- add inline toolbar (bold, italic, code, color, link) on text selection
- add checklist block type with toggle support
- add image block type (URL-based, phase 2)
- add inline serialization helpers (inlineToDom, domToInline)
- add inline types and length utilities
- extend caret utils with range get/set support
- update block registry and all existing block types for new content model
- update demo blocks in ComponentsPage to use rich inline content
- update README to reflect new architecture
This commit is contained in:
2026-04-25 18:27:20 -04:00
parent 3eeaebfa68
commit 5a8d2ad02f
19 changed files with 1244 additions and 126 deletions
@@ -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 (
<div className="w-full flex flex-col gap-4">
@@ -3,9 +3,13 @@
import React, { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react';
import { Add01Icon, DragDropVerticalIcon } from '@zen/core/shared/icons';
import { getBlockDef } from './blockRegistry.js';
import { inlineLength } from './inline/types.js';
import { inlineToDom, domToInline } from './inline/serialize.js';
import {
getCaretOffset,
getCaretRange,
setCaretOffset,
setCaretRange,
focusEnd,
isCaretAtStart,
} from './utils/caret.js';
@@ -17,7 +21,9 @@ import {
// - le drop zone pour le drag-and-drop natif
//
// L'orchestrateur (BlockEditor) reçoit des évènements via les callbacks et
// mute la liste des blocs en conséquence.
// mute la liste des blocs en conséquence. Le contenu inline est un
// `InlineNode[]` (cf. inline/types.js) — sérialisé vers le DOM via
// `inlineToDom` / `domToInline`.
const Block = forwardRef(function Block(
{
@@ -29,6 +35,7 @@ const Block = forwardRef(function Block(
isDragOverBottom,
isSelected,
onContentChange,
onBlockPatch,
onEnter,
onBackspaceAtStart,
onSlashOpen,
@@ -49,6 +56,12 @@ const Block = forwardRef(function Block(
const editableRef = useRef(null);
const [hovered, setHovered] = useState(false);
const [draggable, setDraggable] = useState(false);
// Référence vers le dernier contenu qu'on a écrit dans le DOM. Permet de
// détecter si un changement de `block.content` provient d'un évènement
// externe (undo, transform, toolbar) plutôt que de la frappe utilisateur.
// Si la nouvelle valeur === lastWritten, on ne touche pas au DOM (sinon
// on perdrait le caret).
const lastWrittenRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: (offset) => {
@@ -57,18 +70,26 @@ const Block = forwardRef(function Block(
else focusEnd(editableRef.current);
},
getElement: () => editableRef.current,
getCaretRange: () => getCaretRange(editableRef.current),
setCaretRange: (start, end) => setCaretRange(editableRef.current, start, end),
}), [def]);
// Synchronisation contrôlée → DOM : on n'écrit dans le contentEditable que
// si la valeur externe diffère de ce qui est affiché (cas undo/redo, ou
// transformation de bloc). Sinon on laisse le DOM gérer pour préserver le caret.
// Synchronisation contrôlée → DOM. On compare par référence à
// `lastWrittenRef.current` : si égal, c'est que la valeur reçue est celle
// qu'on vient d'émettre (frappe), donc le DOM est déjà à jour. Sinon on
// réécrit le sous-arbre et on restaure le caret quand l'élément a le focus.
useEffect(() => {
if (!def?.isText) return;
const el = editableRef.current;
if (!el) return;
const next = block.content ?? '';
if (el.textContent !== next) {
el.textContent = next;
if (lastWrittenRef.current === block.content) return;
const hadFocus = document.activeElement === el;
const range = hadFocus ? getCaretRange(el) : null;
el.replaceChildren(inlineToDom(block.content ?? []));
lastWrittenRef.current = block.content;
if (hadFocus && range) {
const total = inlineLength(block.content ?? []);
setCaretRange(el, Math.min(range.start, total), Math.min(range.end, total));
}
}, [block.content, def?.isText]);
@@ -80,6 +101,11 @@ const Block = forwardRef(function Block(
);
}
function emitContent(content) {
lastWrittenRef.current = content;
onContentChange?.(block.id, content);
}
function handleInput() {
const el = editableRef.current;
if (!el) return;
@@ -97,7 +123,7 @@ const Block = forwardRef(function Block(
// Markdown shortcut : si le contenu commence par un préfixe connu suivi
// d'un espace, on demande à l'orchestrateur de transformer le bloc.
if (!def.disableShortcuts) {
const match = text.match(/^(#{1,6} |- |1\. |> |```\s|---)/);
const match = text.match(/^(#{1,6} |- |1\. |> |```\s|---|\[ ?\] )/);
if (match) {
const prefix = match[1];
onShortcutMatch?.({ blockId: block.id, prefix, rest: text.slice(prefix.length) });
@@ -105,7 +131,7 @@ const Block = forwardRef(function Block(
}
}
onContentChange?.(block.id, text);
emitContent(domToInline(el));
}
function handleKeyDown(e) {
@@ -151,8 +177,11 @@ const Block = forwardRef(function Block(
e.preventDefault();
const el = editableRef.current;
const offset = el ? getCaretOffset(el) : 0;
const text = el?.textContent ?? '';
onEnter?.({ blockId: block.id, offset, text });
const content = el ? domToInline(el) : [];
// On synchronise lastWritten : la valeur que `BlockEditor` va écrire
// dans le bloc courant (le before) sera dérivée de ce contenu.
lastWrittenRef.current = null;
onEnter?.({ blockId: block.id, offset, content });
return;
}
@@ -164,7 +193,9 @@ const Block = forwardRef(function Block(
// merge avec le bloc précédent.
if (el && sel?.isCollapsed !== false && isCaretAtStart(el)) {
e.preventDefault();
onBackspaceAtStart?.({ blockId: block.id, text: el.textContent ?? '' });
const content = domToInline(el);
lastWrittenRef.current = null;
onBackspaceAtStart?.({ blockId: block.id, content });
}
}
}
@@ -212,7 +243,9 @@ const Block = forwardRef(function Block(
}
}
const isEmpty = def.isText && (!block.content || block.content.length === 0);
const isEmpty = def.isText && inlineLength(block.content ?? []) === 0;
const checklistChecked = block.type === 'checklist' && !!block.checked;
const checklistClasses = checklistChecked ? ' line-through opacity-60' : '';
const dropIndicator = (
<>
@@ -276,10 +309,10 @@ const Block = forwardRef(function Block(
{def.isText ? (
def.renderPrefix ? (
<div className="flex items-start">
{def.renderPrefix({ block, index, numberedIndex })}
{def.renderPrefix({ block, index, numberedIndex, disabled, onPatch: (patch) => onBlockPatch?.(block.id, patch) })}
<TextEditable
ref={editableRef}
className={`flex-1 outline-none ${def.textClassName || ''} ${isEmpty ? 'block-empty' : ''}`}
className={`flex-1 outline-none ${def.textClassName || ''}${checklistClasses} ${isEmpty ? 'block-empty' : ''}`}
placeholder={def.placeholder}
disabled={disabled}
onInput={handleInput}
@@ -291,7 +324,7 @@ const Block = forwardRef(function Block(
) : (
<TextEditable
ref={editableRef}
className={`outline-none ${def.textClassName || ''} ${isEmpty ? 'block-empty' : ''}`}
className={`outline-none ${def.textClassName || ''}${checklistClasses} ${isEmpty ? 'block-empty' : ''}`}
placeholder={def.placeholder}
disabled={disabled}
onInput={handleInput}
@@ -304,7 +337,7 @@ const Block = forwardRef(function Block(
<def.Component
block={block}
disabled={disabled}
onChange={(patch) => onContentChange?.(block.id, patch)}
onChange={(patch) => onBlockPatch?.(block.id, patch)}
/>
) : (
<div className="text-xs text-red-500">Bloc {block.type} sans rendu.</div>
@@ -3,9 +3,18 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Block from './Block.client.js';
import SlashMenu, { getSlashItems } from './SlashMenu.client.js';
import InlineToolbar from './inline/Toolbar.client.js';
import { getBlockDef, DEFAULT_BLOCK_TYPE } from './blockRegistry.js';
import { registerBuiltInBlocks } from './blockTypes/index.js';
import { newBlockId } from './utils/ids.js';
import {
inlineLength,
inlineToPlainText,
inlineFromText,
sliceInline,
concatInline,
toggleMark,
marksInRange,
} from './inline/types.js';
registerBuiltInBlocks();
@@ -25,6 +34,8 @@ const SHORTCUT_TO_TYPE = {
'> ': 'quote',
'```': 'code',
'---': 'divider',
'[] ': 'checklist',
'[ ] ': 'checklist',
};
function makeBlock(type, init) {
@@ -176,31 +187,6 @@ export default function BlockEditor({
}
// --- Mutations ---
function updateBlock(id, patch) {
const next = blocks.map(b => (b.id === id ? { ...b, ...patch } : b));
commitChange(next);
}
function replaceBlock(id, newBlock, focus = true) {
const next = blocks.map(b => (b.id === id ? newBlock : b));
commitChange(next, { immediate: true });
if (focus) {
setFocusBlockId(newBlock.id);
setFocusOffset(0);
}
}
function insertAfter(id, newBlock, focus = true) {
const idx = blocks.findIndex(b => b.id === id);
if (idx < 0) return;
const next = [...blocks.slice(0, idx + 1), newBlock, ...blocks.slice(idx + 1)];
commitChange(next, { immediate: true });
if (focus) {
setFocusBlockId(newBlock.id);
setFocusOffset(0);
}
}
function removeBlock(id, focusPrev = true) {
const idx = blocks.findIndex(b => b.id === id);
if (idx < 0) return;
@@ -210,26 +196,34 @@ export default function BlockEditor({
if (focusPrev) {
const prev = idx > 0 ? blocks[idx - 1] : finalNext[0];
setFocusBlockId(prev.id);
setFocusOffset(prev.content?.length ?? 0);
setFocusOffset(inlineLength(prev.content));
}
}
// --- Évènements provenant des blocs ---
function handleContentChange(id, text) {
const next = blocks.map(b => (b.id === id ? { ...b, content: text } : b));
function handleContentChange(id, content) {
const next = blocks.map(b => (b.id === id ? { ...b, content } : b));
commitChange(next);
}
function handleEnter({ blockId, offset, text }) {
// Patch arbitraire d'un bloc (ex : checklist `checked`, image `src`).
function handleBlockPatch(id, patch) {
const next = blocks.map(b => (b.id === id ? { ...b, ...patch } : b));
commitChange(next, { immediate: true });
}
function handleEnter({ blockId, offset, content }) {
const idx = blocks.findIndex(b => b.id === blockId);
if (idx < 0) return;
const current = blocks[idx];
const liveContent = content ?? current.content ?? [];
// Sur un item de liste vide, sortir de la liste (devient paragraphe)
if (
(current.type === 'bullet_item' || current.type === 'numbered_item') &&
(text ?? '').length === 0
) {
// Sur un item de liste / checklist vide, sortir en paragraphe
const isListLike =
current.type === 'bullet_item' ||
current.type === 'numbered_item' ||
current.type === 'checklist';
if (isListLike && inlineLength(liveContent) === 0) {
const replaced = makeBlock(DEFAULT_BLOCK_TYPE);
const next = blocks.map(b => (b.id === blockId ? replaced : b));
commitChange(next, { immediate: true });
@@ -238,18 +232,22 @@ export default function BlockEditor({
return;
}
const before = (text ?? '').slice(0, offset);
const after = (text ?? '').slice(offset);
const before = sliceInline(liveContent, 0, offset);
const after = sliceInline(liveContent, offset, inlineLength(liveContent));
// Le bloc courant garde la partie avant le caret
const updated = { ...current, content: before };
// Le nouveau bloc hérite du type pour les listes, sinon paragraphe
const newType =
current.type === 'bullet_item' || current.type === 'numbered_item'
? current.type
: DEFAULT_BLOCK_TYPE;
const newBlock = makeBlock(newType, { content: after });
// Pour la checklist, le nouvel item garde le type mais remet `checked: false`.
let newType;
let newInit = { content: after };
if (current.type === 'bullet_item' || current.type === 'numbered_item') {
newType = current.type;
} else if (current.type === 'checklist') {
newType = 'checklist';
newInit = { content: after, checked: false };
} else {
newType = DEFAULT_BLOCK_TYPE;
}
const newBlock = makeBlock(newType, newInit);
const next = [
...blocks.slice(0, idx),
@@ -262,15 +260,16 @@ export default function BlockEditor({
setFocusOffset(0);
}
function handleBackspaceAtStart({ blockId, text }) {
function handleBackspaceAtStart({ blockId, content }) {
const idx = blocks.findIndex(b => b.id === blockId);
if (idx < 0) return;
const current = blocks[idx];
const liveContent = content ?? current.content ?? [];
// Si le bloc est typé (heading, list, quote, code) et non vide → repasse
// en paragraphe sans rien supprimer.
// Si le bloc est typé (heading, list, quote, code, checklist) et non
// vide → repasse en paragraphe sans rien supprimer.
if (current.type !== DEFAULT_BLOCK_TYPE) {
const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: text ?? '' });
const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: liveContent });
const next = blocks.map(b => (b.id === blockId ? replaced : b));
commitChange(next, { immediate: true });
setFocusBlockId(replaced.id);
@@ -287,8 +286,8 @@ export default function BlockEditor({
removeBlock(blockId, true);
return;
}
const mergedOffset = (prev.content ?? '').length;
const merged = { ...prev, content: (prev.content ?? '') + (text ?? '') };
const mergedOffset = inlineLength(prev.content);
const merged = { ...prev, content: concatInline(prev.content ?? [], liveContent) };
const next = [...blocks.slice(0, idx - 1), merged, ...blocks.slice(idx + 1)];
commitChange(next, { immediate: true });
setFocusBlockId(merged.id);
@@ -319,7 +318,7 @@ export default function BlockEditor({
return;
}
const replaced = def.create({ content: rest ?? '' });
const replaced = def.create({ content: inlineFromText(rest ?? '') });
const next = blocks.map(b => (b.id === blockId ? replaced : b));
commitChange(next, { immediate: true });
setFocusBlockId(replaced.id);
@@ -363,18 +362,19 @@ export default function BlockEditor({
if (def.isText) {
// Remplace le bloc courant en gardant son contenu (purgé du / de query)
const text = (current.content ?? '').replace(/^\/\S*/, '');
const replaced = def.create({ content: text });
const text = inlineToPlainText(current.content ?? []).replace(/^\/\S*/, '');
const replaced = def.create({ content: inlineFromText(text) });
const next = blocks.map(b => (b.id === blockId ? replaced : b));
commitChange(next, { immediate: true });
setFocusBlockId(replaced.id);
setFocusOffset(text.length);
} else {
// Bloc non-texte : insère après et nettoie le bloc courant
const cleared = { ...current, content: (current.content ?? '').replace(/^\/\S*/, '') };
const text = inlineToPlainText(current.content ?? []).replace(/^\/\S*/, '');
const cleared = { ...current, content: inlineFromText(text) };
const inserted = def.create();
const after =
cleared.content.length === 0
text.length === 0
? // remplacer le bloc courant par le bloc non-texte puis ajouter un paragraphe vide
[
...blocks.slice(0, idx),
@@ -436,19 +436,101 @@ export default function BlockEditor({
commitChange(next, { immediate: true });
}
// --- Raccourcis globaux (Undo/Redo seulement) ---
// --- Toolbar de formatage inline ---
// Ancrée au-dessus de la sélection courante quand elle est non-vide et
// qu'elle se trouve dans un contentEditable de l'éditeur.
const [toolbar, setToolbar] = useState(null);
// { blockId, start, end, rect, marks }
const updateToolbar = useCallback(() => {
if (disabled) {
setToolbar(null);
return;
}
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
setToolbar(null);
return;
}
const range = sel.getRangeAt(0);
const container = containerRef.current;
if (!container || !container.contains(range.startContainer) || !container.contains(range.endContainer)) {
setToolbar(null);
return;
}
// Trouve le bloc qui contient la sélection (les deux extrémités doivent
// être dans le même bloc).
const startEl = range.startContainer.nodeType === 1
? range.startContainer
: range.startContainer.parentElement;
const blockEl = startEl?.closest?.('[data-block-id]');
if (!blockEl) { setToolbar(null); return; }
const endEl = range.endContainer.nodeType === 1
? range.endContainer
: range.endContainer.parentElement;
const endBlockEl = endEl?.closest?.('[data-block-id]');
if (!endBlockEl || endBlockEl !== blockEl) { setToolbar(null); return; }
const blockId = blockEl.getAttribute('data-block-id');
const ref = blockRefs.current.get(blockId);
const r = ref?.getCaretRange?.();
if (!r || r.start === r.end) { setToolbar(null); return; }
const rect = range.getBoundingClientRect();
setToolbar({ blockId, start: r.start, end: r.end, rect });
}, [disabled]);
useEffect(() => {
function onSel() { updateToolbar(); }
document.addEventListener('selectionchange', onSel);
return () => document.removeEventListener('selectionchange', onSel);
}, [updateToolbar]);
function applyToggleMark(mark) {
if (!toolbar) return;
const { blockId, start, end } = toolbar;
const block = blocks.find(b => b.id === blockId);
if (!block) return;
const next = toggleMark(block.content ?? [], start, end, mark);
const nextBlocks = blocks.map(b => (b.id === blockId ? { ...b, content: next } : b));
commitChange(nextBlocks, { immediate: true });
// Restaure la sélection après le re-render.
requestAnimationFrame(() => {
const ref = blockRefs.current.get(blockId);
ref?.setCaretRange?.(start, end);
});
}
// --- Raccourcis globaux (Undo/Redo + formatting inline) ---
function handleGlobalKeyDown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
const meta = e.ctrlKey || e.metaKey;
if (meta && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) handleRedo();
else handleUndo();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
if (meta && e.key === 'y') {
e.preventDefault();
handleRedo();
return;
}
if (!meta || e.altKey) return;
const k = e.key.toLowerCase();
let mark = null;
if (k === 'b') mark = { type: 'bold' };
else if (k === 'i') mark = { type: 'italic' };
else if (k === 'u') mark = { type: 'underline' };
else if (k === 'e') mark = { type: 'code' };
if (mark && toolbar) {
e.preventDefault();
applyToggleMark(mark);
return;
}
if (k === 'k' && toolbar) {
e.preventDefault();
const href = window.prompt('URL du lien :', '');
if (href) applyToggleMark({ type: 'link', href });
}
}
// --- Sélection multi-blocs (souris) ---
@@ -567,7 +649,7 @@ export default function BlockEditor({
// Copy/cut : fallback simple — concatène le contenu texte.
const text = blocks
.filter(b => selectedBlockIds.has(b.id))
.map(b => b.content ?? '')
.map(b => inlineToPlainText(b.content ?? []))
.join('\n');
try { e.clipboardData?.setData?.('text/plain', text); } catch {}
if (navigator.clipboard) navigator.clipboard.writeText(text).catch(() => {});
@@ -583,7 +665,7 @@ export default function BlockEditor({
e.preventDefault();
const ch = e.key;
const next = blocks.filter(b => !selectedBlockIds.has(b.id));
const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: ch });
const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: inlineFromText(ch) });
const finalNext = next.length === 0 ? [replaced] : [replaced, ...next];
commitChange(finalNext, { immediate: true });
setSelectedBlockIds(new Set());
@@ -601,10 +683,11 @@ export default function BlockEditor({
const blockEl = target instanceof Element ? target.closest('[data-block-id]') : null;
const editableEl = target instanceof Element ? target.closest('[contenteditable="true"]') : null;
const onHandle = target instanceof Element ? target.closest('button') : null;
const inToolbar = target instanceof Element ? target.closest('[data-inline-toolbar]') : null;
// Boutons (poignée +, drag handle…) : ne pas démarrer de sélection.
if (onHandle) {
if (selectedBlockIds.size > 0) clearBlockSelection();
if (onHandle || inToolbar) {
if (onHandle && selectedBlockIds.size > 0) clearBlockSelection();
return;
}
@@ -704,7 +787,7 @@ export default function BlockEditor({
className={`block-editor border rounded-xl bg-white dark:bg-neutral-900/60 px-3 py-6 ${error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'} ${className}`}
>
<BlockEditorStyles />
{placeholder && blocks.length === 1 && !blocks[0].content && (
{placeholder && blocks.length === 1 && inlineLength(blocks[0].content ?? []) === 0 && (
// Placeholder global injecté via data-placeholder du paragraphe initial
null
)}
@@ -720,6 +803,7 @@ export default function BlockEditor({
isDragOverBottom={dragOver?.blockId === block.id && dragOver.position === 'bottom'}
isSelected={selectedBlockIds.has(block.id)}
onContentChange={handleContentChange}
onBlockPatch={handleBlockPatch}
onEnter={handleEnter}
onBackspaceAtStart={handleBackspaceAtStart}
onShortcutMatch={handleShortcutMatch}
@@ -745,6 +829,17 @@ export default function BlockEditor({
onHoverIndex={(i) => setSlashState(s => ({ ...s, selectedIndex: i }))}
/>
)}
{toolbar && (() => {
const block = blocks.find(b => b.id === toolbar.blockId);
const marks = block ? marksInRange(block.content ?? [], toolbar.start, toolbar.end) : [];
return (
<InlineToolbar
rect={toolbar.rect}
activeMarks={marks}
onToggleMark={applyToggleMark}
/>
);
})()}
{error && (
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
)}
+88 -15
View File
@@ -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 |
|----------------|---------------------------------|----------------------------------|
| `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` | — | `<strong>` |
| `italic` | — | `<em>` |
| `underline` | — | `<u>` |
| `strike` | — | `<s>` |
| `code` | — | `<code>` (monospace, fond gris) |
| `link` | `href: string` | `<a href>` (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
`<BlockEditor />` 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.
@@ -16,9 +16,11 @@ const SHORTCUT_HINT = {
heading_6: '######',
bullet_item: '-',
numbered_item: '1.',
checklist: '[]',
quote: '>',
code: '```',
divider: '---',
image: '',
};
import { listBlocks } from './blockRegistry.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 };
},
};
@@ -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 (
<button
type="button"
role="checkbox"
aria-checked={!!block.checked}
tabIndex={-1}
onMouseDown={(e) => e.preventDefault()}
onClick={toggle}
disabled={disabled}
className={`select-none mt-[0.45em] mr-2 flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center transition-colors ${
block.checked
? 'bg-blue-600 border-blue-600 text-white'
: 'border-neutral-400 dark:border-neutral-500 hover:border-blue-500'
}`}
>
{block.checked && <Tick02Icon width={12} height={12} />}
</button>
);
}
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 <Checkbox block={block} onPatch={onPatch} disabled={disabled} />;
},
create(init = {}) {
return { id: newBlockId(), type: 'checklist', content: [], checked: false, ...init };
},
};
export default Checklist;
@@ -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 };
},
};
@@ -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 };
},
};
}
@@ -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 (
<form
onSubmit={submit}
className="flex items-center gap-2 rounded-lg border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/40 px-3 py-3"
>
<Image01Icon width={18} height={18} className="text-neutral-500" />
<input
ref={inputRef}
type="url"
placeholder="URL de l'image (https://…)"
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={disabled}
className="flex-1 px-2 py-1 text-sm rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white outline-none focus:border-blue-500"
/>
<button
type="submit"
disabled={disabled || !url.trim()}
className="px-3 py-1 text-sm rounded bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:hover:bg-blue-600 text-white"
>
Insérer
</button>
</form>
);
}
return (
<div className="group/image relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={block.src}
alt={block.alt ?? ''}
className="rounded-lg max-w-full block"
draggable={false}
/>
{!disabled && (
<button
type="button"
onClick={reset}
title="Retirer l'image"
className="absolute top-2 right-2 opacity-0 group-hover/image:opacity-100 transition-opacity w-7 h-7 flex items-center justify-center rounded-full bg-black/60 hover:bg-black/80 text-white text-sm"
>
</button>
)}
<div className="mt-1 flex flex-col gap-1">
<input
type="text"
placeholder="Légende (optionnelle)"
value={block.caption ?? ''}
onChange={handleCaptionChange}
disabled={disabled}
className="w-full px-1 py-0.5 text-sm italic text-neutral-600 dark:text-neutral-400 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700"
/>
{!disabled && (
<input
type="text"
placeholder="Texte alternatif (accessibilité)"
value={block.alt ?? ''}
onChange={handleAltChange}
className="w-full px-1 py-0.5 text-xs text-neutral-500 dark:text-neutral-500 bg-transparent outline-none focus:border-b focus:border-neutral-300 dark:focus:border-neutral-700"
/>
)}
</div>
</div>
);
}
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;
@@ -22,7 +22,7 @@ const NumberedItem = {
);
},
create(init = {}) {
return { id: newBlockId(), type: 'numbered_item', content: '', ...init };
return { id: newBlockId(), type: 'numbered_item', content: [], ...init };
},
};
@@ -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 };
},
};
@@ -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 };
},
};
@@ -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);
}
@@ -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';
@@ -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 (
<div
ref={ref}
data-inline-toolbar
className="fixed z-50 flex items-center gap-0.5 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-1 py-1"
style={{ top: pos.top, left: pos.left }}
onMouseDown={(e) => e.preventDefault()}
>
{SIMPLE_BUTTONS.map(btn => (
<button
key={btn.type}
type="button"
title={btn.title}
onClick={() => handleSimple(btn.type)}
className={`w-7 h-7 flex items-center justify-center rounded text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 ${btn.className} ${isActive(btn.type) ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
>
{btn.label}
</button>
))}
<span className="mx-1 w-px h-5 bg-neutral-200 dark:bg-neutral-700" aria-hidden />
<button
type="button"
title="Couleur du texte"
onClick={() => setPopover(p => (p === 'color' ? null : 'color'))}
className={`w-7 h-7 flex items-center justify-center rounded text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 ${isActive('color') ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
>
<span className="font-semibold">A</span>
</button>
<button
type="button"
title="Surlignage"
onClick={() => setPopover(p => (p === 'highlight' ? null : 'highlight'))}
className={`w-7 h-7 flex items-center justify-center rounded text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 ${isActive('highlight') ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
>
<span aria-hidden></span>
</button>
<button
type="button"
title="Lien (Ctrl+K)"
onClick={openLinkPopover}
className={`w-7 h-7 flex items-center justify-center rounded text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 ${isActive('link') ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
>
<span aria-hidden>🔗</span>
</button>
{popover === 'color' && (
<ColorGrid
mode="text"
activeKey={activeMarks.find(m => m.type === 'color')?.color}
onPick={handleColor}
/>
)}
{popover === 'highlight' && (
<ColorGrid
mode="highlight"
activeKey={activeMarks.find(m => m.type === 'highlight')?.color}
onPick={handleHighlight}
/>
)}
{popover === 'link' && (
<form
onSubmit={handleLinkSubmit}
className="absolute top-full left-0 mt-1 flex items-center gap-1 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-2 py-1.5"
>
<input
autoFocus
type="url"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
className="w-56 px-2 py-1 text-sm rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white outline-none focus:border-blue-500"
/>
<button
type="submit"
className="px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white"
>
OK
</button>
{isActive('link') && (
<button
type="button"
onClick={handleLinkRemove}
title="Retirer le lien"
className="px-2 py-1 text-xs rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30"
>
</button>
)}
</form>
)}
</div>
);
}
function ColorGrid({ mode, activeKey, onPick }) {
return (
<div className="absolute top-full left-0 mt-1 flex items-center gap-1 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-2 py-1.5">
{INLINE_COLOR_KEYS.map(key => {
const palette = INLINE_COLORS[key];
const tw = mode === 'text' ? palette.text : palette.highlight;
return (
<button
key={key}
type="button"
title={key}
onClick={() => onPick(key)}
className={`w-6 h-6 flex items-center justify-center rounded ${tw} ${activeKey === key ? 'ring-2 ring-blue-500' : ''}`}
>
<span className={mode === 'text' ? 'font-semibold' : ''}>A</span>
</button>
);
})}
</div>
);
}
@@ -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) :
// <a> > <span data-highlight> > <span data-color> > <strong> > <em> > <u>
// > <s> > <code> > #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;
// <br> : é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 <br> 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);
}
}
@@ -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);
}
@@ -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 (<strong>, <em>, <a>, <span data-color>...).
// 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.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);
}