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:
@@ -37,7 +37,7 @@ function useDropdownPlacement(open, triggerRef) {
|
|||||||
import { getBlockDef, listBlocks } from './blockRegistry.js';
|
import { getBlockDef, listBlocks } from './blockRegistry.js';
|
||||||
import { inlineLength } from './inline/types.js';
|
import { inlineLength } from './inline/types.js';
|
||||||
import { inlineToDom, domToInline } from './inline/serialize.js';
|
import { inlineToDom, domToInline } from './inline/serialize.js';
|
||||||
import { htmlToBlocks } from './inline/clipboard.js';
|
import { htmlToBlocks, notionJsonToBlocks } from './inline/clipboard.js';
|
||||||
import {
|
import {
|
||||||
getCaretOffset,
|
getCaretOffset,
|
||||||
getCaretRange,
|
getCaretRange,
|
||||||
@@ -544,9 +544,29 @@ const Block = forwardRef(function Block(
|
|||||||
|
|
||||||
function handlePaste(e) {
|
function handlePaste(e) {
|
||||||
e.preventDefault();
|
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 html = e.clipboardData.getData('text/html');
|
||||||
const text = e.clipboardData.getData('text/plain');
|
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
|
// Si du HTML est disponible, on tente le parsing rich. Sinon fallback
|
||||||
// sur le texte brut inséré au caret.
|
// sur le texte brut inséré au caret.
|
||||||
if (html) {
|
if (html) {
|
||||||
|
|||||||
@@ -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
|
`<hr>`, `<figure><img>`). Pour les sélections multi-blocs (focus
|
||||||
défocus), on passe par l'API async `navigator.clipboard.write` avec un
|
défocus), on passe par l'API async `navigator.clipboard.write` avec un
|
||||||
`ClipboardItem` qui inclut les deux MIME.
|
`ClipboardItem` qui inclut les deux MIME.
|
||||||
- **Paste dans un bloc** : `Block.handlePaste` lit `text/html` en
|
- **Paste dans un bloc** : `Block.handlePaste` lit d'abord la MIME
|
||||||
priorité et appelle `htmlToBlocks`. Si un seul paragraphe est produit,
|
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
|
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
|
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).
|
(avec fusion tête/queue si extrémités = paragraphe).
|
||||||
|
|||||||
@@ -118,6 +118,76 @@ export function blocksToPlainText(blocks) {
|
|||||||
.join('\n');
|
.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
|
// HTML string → Block[]. `DOMParser` n'exécute pas les scripts ; les tags
|
||||||
// inconnus contribuent uniquement leur contenu inline (via `domToInline`).
|
// inconnus contribuent uniquement leur contenu inline (via `domToInline`).
|
||||||
export function htmlToBlocks(html) {
|
export function htmlToBlocks(html) {
|
||||||
|
|||||||
Reference in New Issue
Block a user