feat(BlockEditor): add notion native json paste support

- import `notionJsonToBlocks` in `Block.client.js` and prioritize `text/_notion-blocks-v3-production` mime over `text/html` on paste
- implement `notionJsonToBlocks` and `notionValueToBlock` in `clipboard.js` to convert notion block json to editor blocks, preserving native types (`to_do`, `sub_sub_header`, etc.)
- update README to document the notion mime priority in paste handling
This commit is contained in:
2026-04-25 21:03:13 -04:00
parent 6a73769d8e
commit a1069c3e3d
3 changed files with 96 additions and 3 deletions
@@ -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 `<ul><li>`, 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) {
+5 -2
View File
@@ -234,8 +234,11 @@ Le presse-papier transporte deux MIME en parallèle : `text/html` (structure
`<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,
- **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 `<ul><li>`. À
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).
@@ -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 `<ul><li>`.
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) {