diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js
index 4279aae..37c20e5 100644
--- a/src/shared/components/BlockEditor/Block.client.js
+++ b/src/shared/components/BlockEditor/Block.client.js
@@ -37,7 +37,7 @@ function useDropdownPlacement(open, triggerRef) {
import { getBlockDef, listBlocks } from './blockRegistry.js';
import { inlineLength } from './inline/types.js';
import { inlineToDom, domToInline } from './inline/serialize.js';
-import { htmlToBlocks } from './inline/clipboard.js';
+import { htmlToBlocks, notionJsonToBlocks } from './inline/clipboard.js';
import {
getCaretOffset,
getCaretRange,
@@ -544,9 +544,29 @@ const Block = forwardRef(function Block(
function handlePaste(e) {
e.preventDefault();
+ // Notion expose ses blocs natifs via sa MIME propriétaire en plus du HTML.
+ // Le HTML aplatit `to_do` en `
`, donc on prend le JSON quand il est
+ // là — sinon on retombe sur le HTML standard.
+ const notionJson = e.clipboardData.getData('text/_notion-blocks-v3-production');
const html = e.clipboardData.getData('text/html');
const text = e.clipboardData.getData('text/plain');
+ if (notionJson) {
+ const pasted = notionJsonToBlocks(notionJson);
+ if (pasted.length > 0) {
+ if (pasted.length === 1) {
+ const only = pasted[0];
+ const onlyDef = getBlockDef(only.type);
+ if (only.type === 'paragraph' && onlyDef?.isText) {
+ onPasteInline?.({ blockId: block.id, inline: only.content ?? [] });
+ return;
+ }
+ }
+ onPasteBlocks?.({ blockId: block.id, blocks: pasted });
+ return;
+ }
+ }
+
// Si du HTML est disponible, on tente le parsing rich. Sinon fallback
// sur le texte brut inséré au caret.
if (html) {
diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md
index b837099..524a238 100644
--- a/src/shared/components/BlockEditor/README.md
+++ b/src/shared/components/BlockEditor/README.md
@@ -234,8 +234,11 @@ Le presse-papier transporte deux MIME en parallèle : `text/html` (structure
``, ``). 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,
+- **Paste dans un bloc** : `Block.handlePaste` lit d'abord la MIME
+ propriétaire `text/_notion-blocks-v3-production` (parsée par
+ `notionJsonToBlocks`) — Notion conserve ses types natifs (`to_do`,
+ `sub_sub_header`, …) là où le HTML aplatit `to_do` en `
`. À
+ défaut, retombe sur `text/html` parsé par `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).
diff --git a/src/shared/components/BlockEditor/inline/clipboard.js b/src/shared/components/BlockEditor/inline/clipboard.js
index 38e79c5..bc4161a 100644
--- a/src/shared/components/BlockEditor/inline/clipboard.js
+++ b/src/shared/components/BlockEditor/inline/clipboard.js
@@ -118,6 +118,76 @@ export function blocksToPlainText(blocks) {
.join('\n');
}
+// JSON Notion (MIME `text/_notion-blocks-v3-production`) → Block[].
+// Notion expose ses blocs natifs en parallèle du HTML ; quand cette MIME
+// est disponible, on l'utilise en priorité car elle conserve les types
+// (`to_do`, `sub_sub_header`, …) que le HTML aplatit en `
`.
+export function notionJsonToBlocks(json) {
+ if (!json) return [];
+ let parsed;
+ try { parsed = typeof json === 'string' ? JSON.parse(json) : json; }
+ catch { return []; }
+ const items = parsed?.blocks;
+ if (!Array.isArray(items)) return [];
+ const out = [];
+ for (const item of items) {
+ const id = item?.blockId;
+ const value = item?.blockSubtree?.block?.[id]?.value;
+ if (!value) continue;
+ const block = notionValueToBlock(value);
+ if (block) out.push(block);
+ }
+ return out;
+}
+
+function notionValueToBlock(value) {
+ const type = value.type;
+ const title = value.properties?.title;
+ const content = notionTitleToInline(title);
+ const checkedProp = value.properties?.checked?.[0]?.[0];
+ const checked = checkedProp === 'Yes' || checkedProp === true;
+
+ switch (type) {
+ case 'header': return { id: newBlockId(), type: 'heading_1', content };
+ case 'sub_header': return { id: newBlockId(), type: 'heading_2', content };
+ case 'sub_sub_header': return { id: newBlockId(), type: 'heading_3', content };
+ case 'to_do': return { id: newBlockId(), type: 'checklist', content, checked };
+ case 'bulleted_list': return { id: newBlockId(), type: 'bullet_item', content };
+ case 'numbered_list': return { id: newBlockId(), type: 'numbered_item', content };
+ case 'quote': return { id: newBlockId(), type: 'quote', content };
+ case 'code': return { id: newBlockId(), type: 'code', content };
+ case 'divider': return { id: newBlockId(), type: 'divider' };
+ case 'text':
+ default: return { id: newBlockId(), type: 'paragraph', content };
+ }
+}
+
+function notionTitleToInline(title) {
+ if (!Array.isArray(title)) return [];
+ const nodes = [];
+ for (const run of title) {
+ if (!Array.isArray(run)) continue;
+ const text = run[0];
+ if (typeof text !== 'string' || text.length === 0) continue;
+ const fmts = run[1];
+ const marks = [];
+ if (Array.isArray(fmts)) {
+ for (const fmt of fmts) {
+ const tag = fmt?.[0];
+ if (tag === 'b') marks.push({ type: 'bold' });
+ else if (tag === 'i') marks.push({ type: 'italic' });
+ else if (tag === '_') marks.push({ type: 'underline' });
+ else if (tag === 's') marks.push({ type: 'strike' });
+ else if (tag === 'c') marks.push({ type: 'code' });
+ else if (tag === 'a' && typeof fmt[1] === 'string') marks.push({ type: 'link', href: fmt[1] });
+ else if (tag === 'h' && typeof fmt[1] === 'string') marks.push({ type: 'color', color: fmt[1] });
+ }
+ }
+ nodes.push(marks.length ? { type: 'text', text, marks } : { type: 'text', text });
+ }
+ return normalize(nodes);
+}
+
// HTML string → Block[]. `DOMParser` n'exécute pas les scripts ; les tags
// inconnus contribuent uniquement leur contenu inline (via `domToInline`).
export function htmlToBlocks(html) {