diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js index 16eab03..390f960 100644 --- a/src/shared/components/BlockEditor/Block.client.js +++ b/src/shared/components/BlockEditor/Block.client.js @@ -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); } diff --git a/src/shared/components/BlockEditor/BlockEditor.client.js b/src/shared/components/BlockEditor/BlockEditor.client.js index cba4b0a..c722cf2 100644 --- a/src/shared/components/BlockEditor/BlockEditor.client.js +++ b/src/shared/components/BlockEditor/BlockEditor.client.js @@ -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} diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md index e322cd1..61a85ff 100644 --- a/src/shared/components/BlockEditor/README.md +++ b/src/shared/components/BlockEditor/README.md @@ -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 + (`
`, `