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:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user