import type { SanitizerConfig } from '../../../../../types/configs/sanitizer-config'; import type { SavedData } from '../../../../../types/data-formats'; import type { BlokModules } from '../../../../types-internal/blok-modules'; import type { Block } from '../../../block'; import { sanitizeBlocks } from '../../../utils/sanitizer'; import type { SanitizerConfigBuilder } from '../sanitizer-config'; import type { ToolRegistry } from '../tool-registry'; import type { HandlerContext, PatternMatch } from '../types'; import type { PasteHandler } from './base'; import { BasePasteHandler } from './base'; import { PatternHandler } from './pattern-handler'; /** * Shape of a block in the Blok clipboard data. * Extends the basic SavedData fields with optional hierarchy information. */ interface BlokClipboardBlock extends Pick { parentId?: string | null; contentIds?: string[]; } /** * Blok Data Handler Priority. * Handles internal Blok JSON data. */ export class BlokDataHandler extends BasePasteHandler implements PasteHandler { private patternHandler?: PatternHandler; constructor( Blok: BlokModules, toolRegistry: ToolRegistry, sanitizerBuilder: SanitizerConfigBuilder, private readonly config: { sanitizer?: SanitizerConfig } ) { super(Blok, toolRegistry, sanitizerBuilder); } canHandle(data: unknown): number { if (typeof data !== 'string') { return 0; } try { JSON.parse(data); return 100; } catch { return 0; } } async handle(data: unknown, context: HandlerContext): Promise { if (typeof data !== 'string') { return false; } const parsedBlokData = JSON.parse(data) as BlokClipboardBlock[]; // Check if we should try pattern matching first (for URL pasting within editor) const hasPatterns = this.toolRegistry.toolsPatterns.length > 0; const plainData = context.plainData; if (!hasPatterns || !plainData) { this.insertBlokBlocks(parsedBlokData, context.canReplaceCurrentBlock); return true; } const patternResult = await this.tryPatternMatch(plainData); if (patternResult) { await this.insertPatternMatch(patternResult, context.canReplaceCurrentBlock); return true; } this.insertBlokBlocks(parsedBlokData, context.canReplaceCurrentBlock); return true; } /** * Try pattern matching before inserting Blok JSON. */ private async tryPatternMatch(plainData: string): Promise { if (!this.patternHandler) { this.patternHandler = new PatternHandler( this.Blok, this.toolRegistry, this.sanitizerBuilder ); } if (plainData.length > PatternHandler.PATTERN_PROCESSING_MAX_LENGTH) { return; } const pattern = this.toolRegistry.findToolForPattern(plainData); if (!pattern) { return; } const event = this.composePasteEvent('pattern', { key: pattern.key, data: plainData, }); return { key: pattern.key, data: plainData, tool: pattern.tool.name, event, }; } /** * Insert a matched pattern as a new block. */ private async insertPatternMatch(patternResult: PatternMatch, canReplace: boolean): Promise { const { BlockManager, Caret } = this.Blok; const insertedBlock = await BlockManager.paste(patternResult.tool, patternResult.event, canReplace); Caret.setToBlock(insertedBlock, Caret.positions.END); } /** * Insert Blok JSON blocks using a two-pass approach: * * Pass 1 — TABLE cell children (blocks whose parentId is a pasted table) are * inserted first. They receive new IDs, which are recorded in a map, so the * owning table can be inserted with its `data.content` references remapped to * the new cell IDs. This prevents container tools (TableCellBlocks) from * resolving old IDs that still exist in the editor and stealing blocks from * the original table. * * Pass 2 — every other block (roots AND flow-nested children such as list * sub-items and toggle/callout/column children) is inserted in DOCUMENT ORDER * with its data remapped. Preserving document order is what keeps a nested * child from rendering above its parent (the Notion-paste scramble bug); * flow-nested blocks have no separate DOM container, so their array position * IS their visual position. * * After both passes the parent-child hierarchy is re-established using the * accumulated old→new ID mapping. */ private insertBlokBlocks( rawBlocks: BlokClipboardBlock[], canReplace: boolean ): void { const { BlockManager, Caret, Tools } = this.Blok; // Some article shapes (e.g. flat-array exports) reference table children // ONLY via `data.content[r][c].blocks = []` and never set parentId // on the children themselves. Backfill parentId before classification so // those children get adopted by the table during the two-pass insert // instead of becoming detached top-level paragraphs. const blocks = backfillTableChildParents(rawBlocks); const sanitizedBlocks = sanitizeBlocks( blocks, (name) => Tools.blockTools.get(name)?.sanitizeConfig ?? {}, this.config.sanitizer ); // Capture replace intent before any insertions move the current block pointer. const shouldReplaceFirst = canReplace && Boolean(BlockManager.currentBlock?.tool.isDefault) && Boolean(BlockManager.currentBlock?.isEmpty); /** * Capture the container membership of the current block BEFORE any * inserts so pasted "root" blocks inherit it. Without this, pasting * into a child of a callout / toggle / table cell would drop the * parent link on the first pasted block and the Saver's * derive-from-live-parentId fallback would emit it as a root sibling * of the container — the "callout paste ejection" regression family. * * Mirrors the `contextParentId` logic in `BasePasteHandler.insertPasteData` * for multi-item HTML/plain paste. `canReplaceCurrentBlock` is * explicitly gated off inside table cells by paste/index.ts, so this * capture is the only place where a table-cell paste picks up its * parent id. */ const currentBlock = BlockManager.currentBlock; const childContainer = currentBlock?.holder?.querySelector('[data-blok-toggle-children]') ?? null; const isInContainerTitle = childContainer !== null && !childContainer.contains(currentBlock?.currentInput ?? null); const contextParentId = isInContainerTitle ? (currentBlock?.id ?? null) : (currentBlock?.parentId ?? null); // IDs of pasted table blocks. ONLY a table's cell children must be inserted // before their parent: the table block's `data.content` references its cell // child IDs and the table tool resolves (adopts) them on insert, so the // cells must already exist with their new IDs. Every OTHER nested block — // list sub-items, toggle/callout/column children — is "flow-nested" and // must be inserted in DOCUMENT ORDER. Inserting those children-first // scrambled the document: a nested list child rendered ABOVE its parent and // the whole top-level order collapsed (Notion paste "broken order" bug). const pastedTableIds = new Set( blocks.filter(b => b.tool === 'table' && b.id !== undefined).map(b => b.id) ); const isTableCellChild = (block: BlokClipboardBlock): boolean => block.parentId !== undefined && block.parentId !== null && pastedTableIds.has(block.parentId); type Entry = { sanitized: (typeof sanitizedBlocks)[number]; original: BlokClipboardBlock }; const tableCells: Entry[] = []; const documentFlow: Entry[] = []; sanitizedBlocks.forEach((sanitizedBlock, i) => { const original = blocks[i]; if (original === undefined) { return; } (isTableCellChild(original) ? tableCells : documentFlow).push({ sanitized: sanitizedBlock, original }); }); /** * Map from original (old) block ID to the newly inserted Block instance. * Used to remap data and restore hierarchy after both passes. */ const oldIdToEntry = new Map(); // Group every insert + setBlockParent call into a single Yjs undo entry // so that one Cmd+Z removes the whole pasted set. Without this wrapper // each BlockManager.insert and setBlockParent lands on its own undo // stack item, and the user has to press undo N times to clear a paste. const runInsertPasses = (): void => { // Pass 1: insert table cells first so they exist with new IDs before the // owning table block is inserted with its (remapped) content references. tableCells.forEach(({ sanitized, original }) => { const block = BlockManager.insert({ tool: sanitized.tool, data: sanitized.data, origin: 'paste' }); oldIdToEntry.set(original.id, { newBlock: block, original }); Caret.setToBlock(block, Caret.positions.END); }); // Build old→new string map for remapping ID references inside parent data. const oldIdToNewId = new Map(); for (const [oldId, { newBlock }] of oldIdToEntry) { oldIdToNewId.set(oldId, newBlock.id); } // Pass 2: insert every remaining block in DOCUMENT ORDER, remapping any // table cell IDs in their data. Skip replace when table cells were // pre-inserted to avoid replacing a just-inserted cell paragraph rather // than the original empty block. documentFlow.forEach(({ sanitized, original }, idx) => { const remappedData = oldIdToNewId.size > 0 ? remapIds(sanitized.data, oldIdToNewId) as typeof sanitized.data : sanitized.data; const block = BlockManager.insert({ tool: sanitized.tool, data: remappedData, replace: idx === 0 && shouldReplaceFirst && tableCells.length === 0, origin: 'paste', }); /** * Wire the root block into the surrounding container membership. * Only applies when the clipboard payload itself did not declare * a parentId for this block (explicit clipboard hierarchy wins, * since it is restored by the pass below). */ if (contextParentId !== null && (original.parentId === undefined || original.parentId === null)) { BlockManager.setBlockParent(block, contextParentId); } oldIdToEntry.set(original.id, { newBlock: block, original }); Caret.setToBlock(block, Caret.positions.END); }); /** * Restore parent-child hierarchy using the old-to-new ID mapping. * Only restores relationships where both parent and child are in the pasted set. */ for (const [, { newBlock, original }] of oldIdToEntry) { if (original.parentId === undefined || original.parentId === null) { continue; } const parentEntry = oldIdToEntry.get(original.parentId); if (parentEntry === undefined) { continue; } // Route through the canonical reparent API — it handles old-parent // splice, new-parent push, DOM reparent into the container's children // wrapper, collapsed-hidden state sync, and the Yjs parentId + // contentIds companion writes. Direct parentId/contentIds mutations // bypass every one of those and reintroduce the callout paste // ejection bug family. BlockManager.setBlockParent(newBlock, parentEntry.newBlock.id); } }; // Older test mocks may not expose transactForTool; fall through // gracefully in that case so unrelated suites keep working. if (typeof BlockManager.transactForTool === 'function') { BlockManager.transactForTool(runInsertPasses); } else { runInsertPasses(); } } } /** * Records each cell-referenced child id under its owning table. */ function collectCellChildIds( cell: unknown, tableId: string, childToTable: Map ): void { if ( typeof cell !== 'object' || cell === null || !Array.isArray((cell as { blocks?: unknown }).blocks) ) { return; } const ids = (cell as { blocks: unknown[] }).blocks; ids.forEach(childId => { if (typeof childId === 'string' && !childToTable.has(childId)) { childToTable.set(childId, tableId); } }); } /** * Walks a clipboard table block's `data.content[r][c]` cells and records * every child id referenced by `cell.blocks` under its owning table. */ function collectTableCellRefs( block: BlokClipboardBlock, childToTable: Map ): void { if (block.tool !== 'table' || block.id === undefined) { return; } const data = block.data as { content?: unknown } | undefined; const content = data?.content; if (!Array.isArray(content)) { return; } const tableId = block.id; content.forEach(row => { if (!Array.isArray(row)) { return; } row.forEach(cell => collectCellChildIds(cell, tableId, childToTable)); }); } /** * Backfills `parentId` on clipboard blocks that are referenced by a sibling * table's `data.content[r][c].blocks` array but never declare a parent of * their own. Idempotent — never overwrites an explicit parentId. */ function backfillTableChildParents( blocks: BlokClipboardBlock[] ): BlokClipboardBlock[] { const childToTable = new Map(); blocks.forEach(block => collectTableCellRefs(block, childToTable)); if (childToTable.size === 0) { return blocks; } return blocks.map(block => { if (block.id === undefined) { return block; } const tableId = childToTable.get(block.id); if (tableId === undefined) { return block; } if (block.parentId !== undefined && block.parentId !== null) { return block; } return { ...block, parentId: tableId }; }); } /** * Recursively walks `value` and replaces any string found as a key in `idMap` * with its mapped value. Used to remap old block IDs to new IDs within a * tool's data object before insertion, so that container blocks (e.g. tables) * reference the correct newly-inserted child block IDs. */ function remapIds(value: unknown, idMap: Map): unknown { if (typeof value === 'string') { return idMap.get(value) ?? value; } if (Array.isArray(value)) { return value.map(item => remapIds(item, idMap)); } if (value !== null && typeof value === 'object') { const result: Record = {}; for (const [k, v] of Object.entries(value as Record)) { result[k] = remapIds(v, idMap); } return result; } return value; }