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 (
diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js index c5e9d7b..050b78c 100644 --- a/src/shared/components/BlockEditor/Block.client.js +++ b/src/shared/components/BlockEditor/Block.client.js @@ -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 ? (
- {def.renderPrefix({ block, index, numberedIndex })} + {def.renderPrefix({ block, index, numberedIndex, disabled, onPatch: (patch) => onBlockPatch?.(block.id, patch) })} onContentChange?.(block.id, patch)} + onChange={(patch) => onBlockPatch?.(block.id, patch)} /> ) : (
Bloc {block.type} sans rendu.
diff --git a/src/shared/components/BlockEditor/BlockEditor.client.js b/src/shared/components/BlockEditor/BlockEditor.client.js index f9d878a..e251372 100644 --- a/src/shared/components/BlockEditor/BlockEditor.client.js +++ b/src/shared/components/BlockEditor/BlockEditor.client.js @@ -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}`} > - {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 ( + + ); + })()} {error && (

{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` | — | `` | +| `code` | — | `` (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 ( +
+ + 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" + /> + + + ); + } + + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {block.alt + {!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' && ( +
+ 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" + /> + + {isActive('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); }