feat(BlockEditor): add rich paste support with html-to-blocks parsing

- add clipboard.js with htmlToBlocks, blocksToHtml, and blocksToPlainText helpers
- handle single-paragraph html paste as inline splice preserving block type
- handle multi-block html paste by splitting current block and merging head/tail paragraphs
- add onPasteInline and onPasteBlocks props to Block component
- implement handlePasteInline and handlePasteBlocks in BlockEditor
- fallback to plain text insertion when html is absent or yields no blocks
- update README to document clipboard behaviour and new paste handlers
This commit is contained in:
2026-04-25 20:19:32 -04:00
parent 547b975c01
commit 085a779c74
6 changed files with 540 additions and 11 deletions
@@ -5,6 +5,7 @@ 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 { htmlToBlocks } from './inline/clipboard.js';
import {
getCaretOffset,
getCaretRange,
@@ -44,6 +45,8 @@ const Block = forwardRef(function Block(
onSelectBlock,
onShortcutMatch,
onFocus,
onPasteInline,
onPasteBlocks,
onDragStart,
onDragEnd,
onDragOver,
@@ -217,10 +220,35 @@ const Block = forwardRef(function Block(
}
function handlePaste(e) {
// MVP : on colle uniquement du texte brut pour éviter le HTML externe.
e.preventDefault();
const html = e.clipboardData.getData('text/html');
const text = e.clipboardData.getData('text/plain');
// Si du HTML est disponible, on tente le parsing rich. Sinon fallback
// sur le texte brut inséré au caret.
if (html) {
const pasted = htmlToBlocks(html);
if (pasted.length === 1) {
const only = pasted[0];
const onlyDef = getBlockDef(only.type);
// Un seul paragraph collé → on splice son contenu inline au caret
// pour conserver l'UX d'un paste « simple » (pas de découpe du bloc).
if (only.type === 'paragraph' && onlyDef?.isText) {
onPasteInline?.({ blockId: block.id, inline: only.content ?? [] });
return;
}
}
if (pasted.length > 0) {
onPasteBlocks?.({ blockId: block.id, blocks: pasted });
return;
}
// pasted vide (HTML sans contenu interprétable) → on retombe sur text
}
if (!text) return;
// execCommand reste la voie la plus simple pour une insertion au caret
// qui s'intègre dans la pile undo native du contentEditable. Sa
// dépréciation n'a pas de remplacement standard à ce jour.
document.execCommand('insertText', false, text);
}
@@ -17,6 +17,8 @@ import {
marksInRange,
collectUsedColors,
} from './inline/types.js';
import { blocksToHtml, blocksToPlainText, htmlToBlocks } from './inline/clipboard.js';
import { newBlockId } from './utils/ids.js';
registerBuiltInBlocks();
@@ -269,6 +271,80 @@ export default function BlockEditor({
setFocusOffset(0);
}
// Paste « inline » : un seul paragraphe collé → splice du contenu au caret
// sans toucher à la liste des blocs (préserve le type du bloc courant).
function handlePasteInline({ blockId, inline }) {
const idx = blocks.findIndex(b => b.id === blockId);
if (idx < 0) return;
if (!Array.isArray(inline) || inline.length === 0) return;
const ref = blockRefs.current.get(blockId);
const range = ref?.getCaretRange?.();
const current = blocks[idx];
const c = current.content ?? [];
const total = inlineLength(c);
const start = Math.min(range?.start ?? total, total);
const end = Math.min(Math.max(range?.end ?? start, start), total);
const before = sliceInline(c, 0, start);
const after = sliceInline(c, end, total);
const merged = concatInline(before, concatInline(inline, after));
const next = blocks.map(b => (b.id === blockId ? { ...b, content: merged } : b));
commitChange(next, { immediate: true });
setFocusBlockId(blockId);
setFocusOffset(start + inlineLength(inline));
}
// Paste multi-blocs : on coupe le bloc courant en deux, on insère les
// blocs collés au milieu, et on fusionne les paragraphes en tête/queue
// si la première/dernière entrée collée est elle-même un paragraphe
// (le bloc courant absorbe / s'absorbe dans le contenu paragraphe).
function handlePasteBlocks({ blockId, blocks: pasted }) {
const idx = blocks.findIndex(b => b.id === blockId);
if (idx < 0) return;
if (!Array.isArray(pasted) || pasted.length === 0) return;
const ref = blockRefs.current.get(blockId);
const range = ref?.getCaretRange?.();
const current = blocks[idx];
const c = current.content ?? [];
const total = inlineLength(c);
const start = Math.min(range?.start ?? total, total);
const end = Math.min(Math.max(range?.end ?? start, start), total);
const before = sliceInline(c, 0, start);
const after = sliceInline(c, end, total);
const list = pasted.map(b => ({ ...b }));
// Fusion tête.
if (list[0].type === 'paragraph' && inlineLength(before) > 0) {
list[0] = {
...list[0],
content: concatInline(before, list[0].content ?? []),
};
} else if (inlineLength(before) > 0) {
list.unshift({ ...current, id: newBlockId(), content: before });
}
// Fusion queue.
const lastIdx = list.length - 1;
const last = list[lastIdx];
if (last.type === 'paragraph' && inlineLength(after) > 0) {
list[lastIdx] = {
...last,
content: concatInline(last.content ?? [], after),
};
} else if (inlineLength(after) > 0) {
list.push({ ...current, id: newBlockId(), content: after });
}
const next = [...blocks.slice(0, idx), ...list, ...blocks.slice(idx + 1)];
commitChange(next, { immediate: true });
// Focus à la fin du dernier bloc collé.
const target = list[list.length - 1];
setFocusBlockId(target.id);
if (target.content) setFocusOffset(inlineLength(target.content));
else setFocusOffset(0);
}
function handleBackspaceAtStart({ blockId, content }) {
const idx = blocks.findIndex(b => b.id === blockId);
if (idx < 0) return;
@@ -677,19 +753,91 @@ export default function BlockEditor({
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C' || e.key === 'x' || e.key === 'X')) {
// Copy/cut : fallback simple — concatène le contenu texte.
const text = blocks
.filter(b => selectedBlockIds.has(b.id))
.map(b => inlineToPlainText(b.content ?? []))
.join('\n');
try { e.clipboardData?.setData?.('text/plain', text); } catch {}
if (navigator.clipboard) navigator.clipboard.writeText(text).catch(() => {});
// Copy/cut multi-blocs : on écrit text/html ET text/plain dans le
// presse-papier via l'API async (ClipboardItem). Ce chemin est
// utilisé hors d'un contentEditable focusé — donc le `copy`/`cut`
// event natif ne se déclenche pas, on doit écrire manuellement.
const selected = blocks.filter(b => selectedBlockIds.has(b.id));
const html = blocksToHtml(selected);
const text = blocksToPlainText(selected);
if (navigator.clipboard && typeof window !== 'undefined' && window.ClipboardItem) {
try {
const item = new window.ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text], { type: 'text/plain' }),
});
navigator.clipboard.write([item]).catch(() => {
navigator.clipboard.writeText(text).catch(() => {});
});
} catch {
navigator.clipboard.writeText(text).catch(() => {});
}
} else if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(() => {});
}
if (e.key === 'x' || e.key === 'X') {
e.preventDefault();
deleteSelectedBlocks();
}
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V')) {
// Paste multi-blocs : remplace les blocs sélectionnés par le contenu
// du presse-papier. On lit le HTML de manière asynchrone via la
// Clipboard API (aucun contentEditable n'est focus, donc pas de
// paste event natif).
e.preventDefault();
if (!navigator.clipboard) return;
const replace = (pasted) => {
if (!Array.isArray(pasted) || pasted.length === 0) return;
const next = [];
let injected = false;
for (const b of blocks) {
if (selectedBlockIds.has(b.id)) {
if (!injected) { next.push(...pasted); injected = true; }
} else {
next.push(b);
}
}
if (!injected) next.push(...pasted);
const finalNext = next.length === 0 ? [makeBlock(DEFAULT_BLOCK_TYPE)] : next;
commitChange(finalNext, { immediate: true });
setSelectedBlockIds(new Set());
const target = pasted[pasted.length - 1];
setFocusBlockId(target.id);
setFocusOffset(target.content ? inlineLength(target.content) : 0);
};
if (navigator.clipboard.read) {
navigator.clipboard.read().then(async (items) => {
for (const item of items) {
if (item.types.includes('text/html')) {
const blob = await item.getType('text/html');
const html = await blob.text();
replace(htmlToBlocks(html));
return;
}
}
for (const item of items) {
if (item.types.includes('text/plain')) {
const blob = await item.getType('text/plain');
const text = await blob.text();
if (text) replace([{ id: newBlockId(), type: DEFAULT_BLOCK_TYPE, content: inlineFromText(text) }]);
return;
}
}
}).catch(() => {
// Permission refusée → fallback texte brut
navigator.clipboard.readText?.().then(text => {
if (text) replace([{ id: newBlockId(), type: DEFAULT_BLOCK_TYPE, content: inlineFromText(text) }]);
}).catch(() => {});
});
} else if (navigator.clipboard.readText) {
navigator.clipboard.readText().then(text => {
if (text) replace([{ id: newBlockId(), type: DEFAULT_BLOCK_TYPE, content: inlineFromText(text) }]);
}).catch(() => {});
}
return;
}
// Frappe utile (caractère imprimable) → supprimer puis insérer un nouveau
// paragraphe avec ce caractère.
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.length === 1) {
@@ -858,6 +1006,8 @@ export default function BlockEditor({
onSlashClose={handleSlashClose}
onSelectAllBlocks={handleBlockSelectAll}
onSelectBlock={selectBlock}
onPasteInline={handlePasteInline}
onPasteBlocks={handlePasteBlocks}
onDragStart={() => setDragOver(null)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
+34 -3
View File
@@ -78,6 +78,7 @@ import {
sliceInline, concatInline, applyMark, toggleMark, removeAllMarks,
marksAtOffset, marksInRange, INLINE_COLORS,
isHexColor, collectUsedColors,
blocksToHtml, blocksToPlainText, htmlToBlocks,
} from '@zen/core/shared/components/BlockEditor';
```
@@ -142,7 +143,8 @@ 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é (texte plat)
- `Ctrl/Cmd + C` / `Ctrl/Cmd + X` → copie/coupe les blocs sélectionnés au format **HTML structuré** (titres, gras, listes, citations, …) + fallback `text/plain`. Collable dans Word, Google Docs, Slack, ou un autre `BlockEditor`.
- `Ctrl/Cmd + V` → remplace les blocs sélectionnés par le contenu HTML du presse-papier (parsé par `htmlToBlocks`).
- frappe d'un caractère imprimable → remplace les blocs sélectionnés par un nouveau paragraphe contenant ce caractère
- clic dans l'éditeur → quitte la sélection
@@ -201,11 +203,40 @@ utils/ids.js UUID pour les blocs
utils/caret.js gestion du caret (multi-Text-nodes)
```
## Copier-coller avec formatage
Le presse-papier transporte deux MIME en parallèle : `text/html` (structure
+ formatage) et `text/plain` (fallback). Côté éditeur :
- **Copy / Cut** : `blocksToHtml(selected)` produit un HTML standard
(`<h1>…<h6>`, `<p>`, `<ul>/<ol><li>`, `<blockquote>`, `<pre><code>`,
`<hr>`, `<figure><img>`). Pour les sélections multi-blocs (focus
défocus), on passe par l'API async `navigator.clipboard.write` avec un
`ClipboardItem` qui inclut les deux MIME.
- **Paste dans un bloc** : `Block.handlePaste` lit `text/html` en
priorité et appelle `htmlToBlocks`. Si un seul paragraphe est produit,
son contenu inline est splicé au caret. Sinon le bloc courant est
coupé en deux et les blocs collés sont insérés entre les deux moitiés
(avec fusion tête/queue si extrémités = paragraphe).
- **Paste en sélection multi-blocs** : `navigator.clipboard.read()` lit
le HTML du presse-papier, le convertit, et remplace les blocs
sélectionnés.
Tags HTML reconnus en entrée : `<h1>``<h6>`, `<p>`, `<ul>/<ol><li>`,
`<ul data-checklist>` ou `<li>` contenant `<input type="checkbox">`,
`<blockquote>`, `<pre>`, `<code>`, `<hr>`, `<figure>`, `<img>`, plus
toutes les marks de `domToInline` (`<strong>/<b>`, `<em>/<i>`, `<u>`,
`<s>/<strike>/<del>`, `<code>`, `<a href>`, `<span data-color>` /
`<span data-highlight>`).
Les wrappers de Word / Google Docs (`<b id=...>`, `<div>` de mise en
page) sont traversés pour atteindre les éléments block-level
descendants. Les styles CSS inline (`font-weight`, `font-style`,
`text-decoration`) sont également lus en plus des tags sémantiques.
## Limitations connues
- Pas d'imbrication de listes.
- 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.
@@ -37,3 +37,4 @@ export {
normalize as normalizeInline,
} from './inline/types.js';
export { inlineToDom, domToInline } from './inline/serialize.js';
export { blocksToHtml, blocksToPlainText, htmlToBlocks } from './inline/clipboard.js';
@@ -0,0 +1,292 @@
// Sérialisation `Block[]` ↔ HTML pour le presse-papier.
//
// `blocksToHtml` produit un HTML structuré qu'un autre éditeur (Word, Google
// Docs, un autre BlockEditor, …) peut interpréter. `htmlToBlocks` fait
// l'inverse : du HTML quelconque (provenance externe ou interne) vers la
// liste de blocs typés.
//
// Module **neutre** — pas d'import React. Utilise le `document` global
// (paste/copy s'exécutent toujours côté client) et `DOMParser` pour le
// parsing.
import { newBlockId } from '../utils/ids.js';
import { inlineToDom, domToInline } from './serialize.js';
import { inlineFromText, inlineToPlainText, normalize } from './types.js';
const HEADING_RE = /^heading_([1-6])$/;
const BLOCK_TAG_RE = /^(P|H[1-6]|UL|OL|BLOCKQUOTE|PRE|HR|FIGURE|DIV|TABLE)$/;
// Block[] → HTML string. Regroupe les listes consécutives sous un seul
// <ul>/<ol>. Les blocs inconnus deviennent un <p> au texte aplati.
export function blocksToHtml(blocks) {
if (!Array.isArray(blocks) || blocks.length === 0) return '';
if (typeof document === 'undefined') return '';
const fragment = document.createDocumentFragment();
let i = 0;
while (i < blocks.length) {
const block = blocks[i];
if (
block.type === 'bullet_item' ||
block.type === 'numbered_item' ||
block.type === 'checklist'
) {
const tag = block.type === 'numbered_item' ? 'ol' : 'ul';
const list = document.createElement(tag);
if (block.type === 'checklist') list.setAttribute('data-checklist', '');
while (i < blocks.length && blocks[i].type === block.type) {
const li = document.createElement('li');
if (blocks[i].type === 'checklist') {
const cb = document.createElement('input');
cb.type = 'checkbox';
if (blocks[i].checked) cb.setAttribute('checked', '');
li.appendChild(cb);
li.appendChild(document.createTextNode(' '));
}
li.appendChild(inlineToDom(blocks[i].content ?? []));
list.appendChild(li);
i++;
}
fragment.appendChild(list);
continue;
}
fragment.appendChild(blockToElement(block));
i++;
}
const wrapper = document.createElement('div');
wrapper.appendChild(fragment);
return wrapper.innerHTML;
}
function blockToElement(block) {
const heading = HEADING_RE.exec(block.type);
if (heading) {
const h = document.createElement(`h${heading[1]}`);
h.appendChild(inlineToDom(block.content ?? []));
return h;
}
if (block.type === 'paragraph') {
const p = document.createElement('p');
p.appendChild(inlineToDom(block.content ?? []));
return p;
}
if (block.type === 'quote') {
const bq = document.createElement('blockquote');
bq.appendChild(inlineToDom(block.content ?? []));
return bq;
}
if (block.type === 'code') {
const pre = document.createElement('pre');
const code = document.createElement('code');
code.appendChild(inlineToDom(block.content ?? []));
pre.appendChild(code);
return pre;
}
if (block.type === 'divider') {
return document.createElement('hr');
}
if (block.type === 'image') {
const fig = document.createElement('figure');
const img = document.createElement('img');
img.setAttribute('src', block.src || '');
if (block.alt) img.setAttribute('alt', block.alt);
fig.appendChild(img);
if (block.caption) {
const cap = document.createElement('figcaption');
cap.textContent = block.caption;
fig.appendChild(cap);
}
return fig;
}
// Type inconnu : on aplatit en paragraphe.
const p = document.createElement('p');
p.textContent = inlineToPlainText(block.content ?? []);
return p;
}
// Block[] → texte brut pour le MIME `text/plain` complémentaire.
export function blocksToPlainText(blocks) {
if (!Array.isArray(blocks) || blocks.length === 0) return '';
return blocks
.map(b => {
if (b.type === 'divider') return '---';
if (b.type === 'image') return b.alt || b.caption || '';
return inlineToPlainText(b.content ?? []);
})
.join('\n');
}
// HTML string → Block[]. `DOMParser` n'exécute pas les scripts ; les tags
// inconnus contribuent uniquement leur contenu inline (via `domToInline`).
export function htmlToBlocks(html) {
if (!html || typeof DOMParser === 'undefined') return [];
const doc = new DOMParser().parseFromString(html, 'text/html');
const out = [];
parseChildren(doc.body, out);
return out;
}
function parseChildren(node, out) {
const buffer = { nodes: [] };
function flush() {
if (buffer.nodes.length === 0) return;
const content = normalize(buffer.nodes);
buffer.nodes = [];
if (content.length === 0) return;
out.push({ id: newBlockId(), type: 'paragraph', content });
}
for (const child of node.childNodes) {
if (child.nodeType === 3 /* TEXT_NODE */) {
const t = child.nodeValue;
if (t && t.trim()) buffer.nodes.push({ type: 'text', text: t });
continue;
}
if (child.nodeType !== 1 /* ELEMENT_NODE */) continue;
const tag = child.tagName;
const heading = /^H([1-6])$/.exec(tag);
if (heading) {
flush();
const content = domToInline(child);
if (content.length > 0) {
out.push({ id: newBlockId(), type: `heading_${heading[1]}`, content });
}
continue;
}
if (tag === 'P') {
flush();
const content = domToInline(child);
if (content.length > 0) {
out.push({ id: newBlockId(), type: 'paragraph', content });
}
continue;
}
if (tag === 'UL' || tag === 'OL') {
flush();
parseList(child, tag === 'OL', out);
continue;
}
if (tag === 'BLOCKQUOTE') {
flush();
const content = domToInline(child);
if (content.length > 0) {
out.push({ id: newBlockId(), type: 'quote', content });
}
continue;
}
if (tag === 'PRE') {
flush();
const codeEl = child.querySelector('code') || child;
const text = codeEl.textContent || '';
out.push({ id: newBlockId(), type: 'code', content: inlineFromText(text) });
continue;
}
if (tag === 'HR') {
flush();
out.push({ id: newBlockId(), type: 'divider' });
continue;
}
if (tag === 'FIGURE') {
flush();
const img = child.querySelector('img');
if (img) {
const cap = child.querySelector('figcaption');
out.push({
id: newBlockId(),
type: 'image',
src: img.getAttribute('src') || '',
alt: img.getAttribute('alt') || '',
caption: cap?.textContent?.trim() || '',
});
}
continue;
}
if (tag === 'IMG') {
flush();
out.push({
id: newBlockId(),
type: 'image',
src: child.getAttribute('src') || '',
alt: child.getAttribute('alt') || '',
caption: '',
});
continue;
}
if (tag === 'BR') {
// Saut de ligne au top-level → coupure de paragraphe.
flush();
continue;
}
// Wrappers Google Docs / Word ou <div> de mise en page : si l'élément
// contient au moins un descendant block-level, on flush le buffer puis
// on recurse dedans pour récupérer la structure. Sinon on traite
// l'élément comme du contenu inline.
if (hasBlockDescendant(child)) {
flush();
parseChildren(child, out);
continue;
}
// Inline : ajouter au buffer (paragraphe en cours d'accumulation).
const inline = domToInline(child);
buffer.nodes.push(...inline);
}
flush();
}
function hasBlockDescendant(el) {
for (const c of el.children) {
if (BLOCK_TAG_RE.test(c.tagName)) return true;
if (hasBlockDescendant(c)) return true;
}
return false;
}
function parseList(listEl, ordered, out) {
const isChecklist = listEl.hasAttribute('data-checklist');
for (const li of listEl.children) {
if (li.tagName !== 'LI') continue;
// Détection checkbox (héritée du data-checklist OU d'un <input type=checkbox>
// dans le <li>, à la Markdown task list).
const checkbox = li.querySelector(':scope > input[type="checkbox"]');
const isChecklistItem = isChecklist || !!checkbox;
let checked = false;
if (checkbox) {
checked = checkbox.checked || checkbox.hasAttribute('checked');
checkbox.remove();
}
// Si le <li> contient lui-même des sous-listes, on émet d'abord un
// item pour son contenu inline, puis on traite les sous-listes comme
// des items frères (pas de nesting dans notre modèle).
const subLists = Array.from(li.children).filter(
c => c.tagName === 'UL' || c.tagName === 'OL',
);
for (const sub of subLists) sub.remove();
const content = domToInline(li);
if (content.length > 0 || isChecklistItem) {
if (isChecklistItem) {
out.push({ id: newBlockId(), type: 'checklist', content, checked: !!checked });
} else if (ordered) {
out.push({ id: newBlockId(), type: 'numbered_item', content });
} else {
out.push({ id: newBlockId(), type: 'bullet_item', content });
}
}
for (const sub of subLists) parseList(sub, sub.tagName === 'OL', out);
}
}
@@ -170,6 +170,33 @@ function walk(node, marks, out) {
if (highlight) added.push({ type: 'highlight', color: highlight });
}
// Styles CSS inline : Google Docs / Word produisent massivement des
// <span style="font-weight:700"> / <b style="font-weight:normal"> au lieu
// de <strong>/<em>. On lit ces styles pour préserver le formatage à la
// collaboration externe.
const style = node.style;
if (style) {
const fw = style.fontWeight;
const isBoldStyle = fw === 'bold' || fw === 'bolder' || (fw && parseInt(fw, 10) >= 600);
const isNormalStyle = fw === 'normal' || (fw && parseInt(fw, 10) > 0 && parseInt(fw, 10) < 600);
// Un <b style="font-weight:normal"> annule le bold du tag (cas Google Docs).
if (simple === 'bold' && isNormalStyle) {
added.length = 0;
} else if (!simple && isBoldStyle && !added.some(m => m.type === 'bold')) {
added.push({ type: 'bold' });
}
if (style.fontStyle === 'italic' && !added.some(m => m.type === 'italic')) {
added.push({ type: 'italic' });
}
const decoLine = style.textDecorationLine || style.textDecoration || '';
if (decoLine.includes('underline') && !added.some(m => m.type === 'underline')) {
added.push({ type: 'underline' });
}
if (decoLine.includes('line-through') && !added.some(m => m.type === 'strike')) {
added.push({ type: 'strike' });
}
}
const nextMarks = added.length ? [...marks, ...added] : marks;
for (const child of node.childNodes) {
walk(child, nextMarks, out);