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
@@ -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) {