`s. The
+ * generic walker is transparent across `
`s, so we only need to
+ * strip Notion's wrapper if it exists.
+ */
+function notionHtmlToBlocks(html: string): BlockTree {
+ const body = parseHtmlBody(html);
+ // Notion sometimes wraps the whole paste in a single
+ // `
`. Treat it as transparent.
+ const wrapper = body.querySelector('div.notion-selectable');
+ const root = wrapper !== null ? (wrapper as HTMLElement) : body;
+ return htmlElementToBlocks(root);
+}
+
+/**
+ * Convert Markdown text into blocks. We don't take a Markdown parser
+ * dependency — paste workflows want predictable output more than they
+ * want CommonMark fidelity. Supported syntax:
+ *
+ * - `#`..`######` headings
+ * - `-`, `*`, `+` unordered lists
+ * - `1.` ordered lists
+ * - ``` ``` fenced code blocks ```
+ * - blank line as paragraph separator
+ * - everything else is a paragraph
+ *
+ * Future work: link parsing, inline emphasis. Out of scope for the
+ * paste-pipeline issue — the editor's rich-text layer handles those
+ * once a block exists.
+ */
+export function markdownToBlocks(input: string): BlockTree {
+ const out: BlockTree = [];
+ const lines = input.split(/\r?\n/);
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i] ?? '';
+ // Fenced code.
+ if (/^```/.test(line)) {
+ const codeLines: string[] = [];
+ i++;
+ while (i < lines.length && !/^```/.test(lines[i] ?? '')) {
+ codeLines.push(lines[i] ?? '');
+ i++;
+ }
+ if (i < lines.length) i++; // consume closing fence
+ out.push({
+ type: 'core/code',
+ attributes: { code: codeLines.join('\n') },
+ });
+ continue;
+ }
+ // Heading.
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
+ if (heading !== null) {
+ out.push({
+ type: 'core/heading',
+ attributes: {
+ level: heading[1]?.length ?? 2,
+ text: heading[2]?.trim() ?? '',
+ },
+ });
+ i++;
+ continue;
+ }
+ // Unordered list.
+ if (/^\s*[-*+]\s+/.test(line)) {
+ const items: Block[] = [];
+ while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i] ?? '')) {
+ const text = (lines[i] ?? '').replace(/^\s*[-*+]\s+/, '').trim();
+ items.push({ type: 'core/list-item', attributes: { text } });
+ i++;
+ }
+ out.push({
+ type: 'core/list',
+ attributes: { ordered: false },
+ innerBlocks: items,
+ });
+ continue;
+ }
+ // Ordered list.
+ if (/^\s*\d+\.\s+/.test(line)) {
+ const items: Block[] = [];
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i] ?? '')) {
+ const text = (lines[i] ?? '').replace(/^\s*\d+\.\s+/, '').trim();
+ items.push({ type: 'core/list-item', attributes: { text } });
+ i++;
+ }
+ out.push({
+ type: 'core/list',
+ attributes: { ordered: true },
+ innerBlocks: items,
+ });
+ continue;
+ }
+ // Blank line — flush.
+ if (/^\s*$/.test(line)) {
+ i++;
+ continue;
+ }
+ // Paragraph — gather consecutive non-empty lines.
+ const paraLines: string[] = [];
+ while (
+ i < lines.length &&
+ !/^\s*$/.test(lines[i] ?? '') &&
+ !/^(#{1,6})\s+/.test(lines[i] ?? '') &&
+ !/^\s*[-*+]\s+/.test(lines[i] ?? '') &&
+ !/^\s*\d+\.\s+/.test(lines[i] ?? '') &&
+ !/^```/.test(lines[i] ?? '')
+ ) {
+ paraLines.push((lines[i] ?? '').trim());
+ i++;
+ }
+ if (paraLines.length > 0) {
+ out.push({
+ type: 'core/paragraph',
+ attributes: { text: paraLines.join(' ') },
+ });
+ }
+ }
+ return out;
+}
+
+/**
+ * Top-level converter. Dispatches to the per-source converter based on
+ * the detected source. Exported for tests + callers that already have
+ * a `DetectedPaste` in hand.
+ */
+export function convertPaste(detected: DetectedPaste): BlockTree {
+ switch (detected.source) {
+ case 'gdocs':
+ return gdocsHtmlToBlocks(detected.html);
+ case 'word':
+ return wordHtmlToBlocks(detected.html);
+ case 'notion':
+ return notionHtmlToBlocks(detected.html);
+ case 'markdown':
+ return markdownToBlocks(detected.text);
+ case 'html':
+ return htmlElementToBlocks(parseHtmlBody(detected.html));
+ case 'text':
+ // Split on blank lines so each paragraph becomes its own block.
+ return detected.text
+ .split(/\r?\n\s*\r?\n/)
+ .map((para) => para.trim())
+ .filter((para) => para.length > 0)
+ .map((para) => ({
+ type: 'core/paragraph' as const,
+ attributes: { text: para },
+ }));
+ }
+}
+
+/**
+ * The shape host code wires into the canvas via `onPaste`. The handler
+ * reads the clipboard, runs detection + conversion, and returns the
+ * resulting tree. The host decides where to splice it into the
+ * document (at caret, after selected block, etc).
+ *
+ * Calling `preventDefault()` is the host's call — sometimes the user
+ * wants the browser's default paste (e.g. into a text input inside an
+ * inspector control). We return `null` when there is nothing to
+ * convert so the host can decide whether to suppress the default.
+ */
+export function onPaste(event: ClipboardEvent): BlockTree | null {
+ const data = event.clipboardData;
+ if (data === null) return null;
+ const detected = detectPasteSource({
+ html: data.getData('text/html'),
+ text: data.getData('text/plain'),
+ });
+ const blocks = convertPaste(detected);
+ return blocks.length > 0 ? blocks : null;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 294a7421..6104cb8f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -300,6 +300,15 @@ importers:
packages/ts/blocks-editor:
dependencies:
+ '@dnd-kit/core':
+ specifier: ^6.1.0
+ version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@dnd-kit/sortable':
+ specifier: ^8.0.0
+ version: 8.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@19.2.6)
'@gonext/blocks-sdk':
specifier: workspace:*
version: link:../blocks-sdk
@@ -765,6 +774,28 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
+ '@dnd-kit/accessibility@3.1.1':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/core@6.3.1':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/sortable@8.0.0':
+ resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.1.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/utilities@3.2.2':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -5114,6 +5145,31 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
+ '@dnd-kit/accessibility@3.1.1(react@19.2.6)':
+ dependencies:
+ react: 19.2.6
+ tslib: 2.8.1
+
+ '@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@19.2.6)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.6)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ tslib: 2.8.1
+
+ '@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.6)
+ react: 19.2.6
+ tslib: 2.8.1
+
+ '@dnd-kit/utilities@3.2.2(react@19.2.6)':
+ dependencies:
+ react: 19.2.6
+ tslib: 2.8.1
+
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1