Blocks API: insert, move, delete, and render blocks
Manage blocks in the editor — create, delete, update, and reorder content.
Reaching the editor instance
The methods below run on the editor you created with new Blok(). They are available once editor.isReady resolves.
// You already hold the instance returned by the constructor.
const editor = new Blok({ holder: 'editor' });
await editor.isReady;
// Call any API method on it.
editor.caret.setToLastBlock('end');Methods
blocks.clear()
Promise<void>Remove all blocks from the editor.
When to use
Same as the top-level clear(), exposed on the blocks module. Use when you already hold editor.blocks.
await editor.blocks.clear();
// All content removed; editor keeps one empty paragraphblocks.render(data)
Promise<void>Render passed JSON data as blocks, replacing the current document. Echo-safe: when the incoming document is structurally equal to the current saved content (`time`/`version` ignored), the call is a caret-preserving no-op — the `data → render → onSave → setState → data` round-trip needs no consumer-side dedupe. Accepts the loose wire shape (`LooseOutputData`); the editor deep-clones the data, so the passed object is never mutated or retained — frozen store state (Redux, Immer) can be passed directly.
When to use
Replaces the whole document. Echoing the editor's own output back is a caret-preserving no-op — no consumer-side dedupe needed. To add blocks without clearing, use insert() or insertMany().
const data = {
blocks: [
{ id: '1', type: 'paragraph', data: { text: 'Hello World' } },
{ id: '2', type: 'header', data: { text: 'Title', level: 1 } }
]
};
await editor.blocks.render(data);blocks.renderFromHTML(data)
Promise<void>Render HTML string as blocks by converting it to block format.
When to use
Handy for importing legacy HTML, but the result is only as good as the tools' paste handlers — JSON via render() preserves more fidelity when you control the source.
const html = '<h1>Title</h1><p>Hello World</p>';
await editor.blocks.renderFromHTML(html);
// HTML is converted to appropriate blocksblocks.importMarkdown(md, options?)
Promise<OutputData>Convert a Markdown string to blocks and render them, REPLACING the current document — it calls `blocks.render()` internally. The converter is lazy-loaded on first call, and the resolved OutputData is the document that was rendered. `options` is a `MarkdownImportConfig` (tool mapping, GFM toggle, micromark/mdast extensions). For additive insertion, use `markdownToBlocks()` from the standalone `@bloklabs/core/markdown` subpath together with `blocks.insertMany()`.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
md | string | Required | — | Markdown source string. |
options | MarkdownImportConfig | — | undefined | Tool mapping, GFM toggle, and micromark/mdast extensions. |
const data = await editor.blocks.importMarkdown('# Title\n\n- one\n- two');
// The whole document is replaced; data is the rendered OutputDatablocks.exportMarkdown()
Promise<string>Serialize the current document to Markdown — the outbound twin of `importMarkdown`. Blocks are read through the Saver, so the output reflects the saved (validated) document rather than raw DOM, and the promise resolves to '' when there is nothing to save. Blocks owned by a table cell are serialized inside the pipe table instead of being repeated as loose lines. What Markdown cannot express is degraded: table `colspan`/`rowspan` and heading columns are dropped, and a table with no heading row gets an empty header row, since GFM requires one.
const md = await editor.blocks.exportMarkdown();
// → '# Title\n\n- one\n- two'blocks.delete(index?, setCaret?)
Promise<void>Remove the block at the specified index, or current block if no index provided.
When to use
Without an index it deletes the focused block. Deleting the last remaining block automatically inserts a fresh empty paragraph, so the editor is never left without a caret target.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
index | number | — | current block index | Index of the block to delete. |
setCaret | boolean | — | true | Whether to move the caret to the surviving block after deletion; pass false to avoid stealing the user's caret during programmatic deletion. |
// Delete current block
await editor.blocks.delete();
// Delete block at index 0
await editor.blocks.delete(0);
// Delete without moving the user's caret (programmatic deletion)
await editor.blocks.delete(0, false);blocks.move(toIndex, fromIndex?)
voidMoves a block to a new position. If fromIndex is not provided, moves the current block.
When to use
toIndex is in post-removal index space: for a forward move, subtract 1 or the block lands one slot too far.
// Move current block to top
editor.blocks.move(0);
// Move block from index 2 to index 0
editor.blocks.move(0, 2);blocks.getBlockByIndex(index)
BlockAPI | undefinedGet the BlockAPI object for the block at the specified index.
When to use
Returns undefined for out-of-range indexes — calling a method on an undefined result throws, so check the return value first.
const block = editor.blocks.getBlockByIndex(0);
if (block) {
console.log(block.id, block.name);
}blocks.getById(id)
BlockAPI | nullGet the BlockAPI object for the block with the specified ID.
When to use
The stable way to reference a block across edits, since indexes shift. Returns null if the id no longer exists.
const block = editor.blocks.getById('block-123');
if (block) {
await editor.blocks.update(block.id, { text: 'New content' });
}blocks.getCurrentBlockIndex()
numberGet the index of the currently focused block.
When to use
Reflects the block holding the caret. Returns -1 when nothing is focused, so check before using it as an index.
const index = editor.blocks.getCurrentBlockIndex();
console.log('Current block index:', index);blocks.getBlockIndex(blockId)
number | undefinedGet the index of a block by its ID.
When to use
Finds where a known block sits right now; pair with getById() when you have an id but need a position.
const index = editor.blocks.getBlockIndex('block-123');
if (index !== undefined) {
console.log('Block is at index:', index);
}blocks.getBlockByElement(element)
BlockAPI | undefinedGet the BlockAPI object for the block containing the given HTML element.
When to use
Bridges from a raw DOM node (e.g. a click target) back to its block — useful in custom event handlers.
document.addEventListener('click', (e) => {
const block = editor.blocks.getBlockByElement(e.target);
if (block) {
console.log('Clicked on block:', block.id);
}
});blocks.scrollToBlock(id)
voidScroll a block into view, select it, pulse the arrival highlight and announce the navigation to assistive tech — the public counterpart of the boot-time URL-hash scroll. No-op when no block with that id is in the document. Framework adapters that mount into a detached holder (React/Vue/Angular) render seeded content before it joins the page, so the boot hash scroll defers; @bloklabs/react drains it automatically once the holder connects — call this yourself for deep-linking after the editor is ready.
editor.blocks.scrollToBlock(nodeId);blocks.getChildren(parentId)
BlockAPI[]Get all child blocks of a parent container block.
When to use
Returns the direct children of a container block (a column, toggle, or database). Empty array if it has none.
const children = editor.blocks.getChildren('parent-block-id');
children.forEach(child => {
console.log('Child:', child.id);
});blocks.setBlockParent(blockId, parentId)
voidReparent a block: updates the block's `parentId` and the parent's `contentIds` through core's single reparent chokepoint. Pass `null` to move the block back to the root level. An unknown `blockId` is a no-op that logs a warning; a `parentId` that would make the block a descendant of itself throws.
// Move a block into a container block
editor.blocks.setBlockParent('child-block-id', 'parent-block-id');
// Move it back to the root level
editor.blocks.setBlockParent('child-block-id', null);blocks.insertInsideParent(parentId, insertIndex, childData?, toolName?)
BlockAPIInsert a block as a child of `parentId` atomically: the creation and the parent assignment are grouped into ONE undo entry, so a single Cmd+Z removes it completely (preferable to `insert()` followed by a reparent, which is two). `insertIndex` is the FLAT document index where the child should appear. `toolName` picks the child's block tool and defaults to `config.defaultBlock`; a tool that is restricted inside table cells is demoted to the default block when the new child would land inside one, and an unregistered name throws before anything is written. `childData` defaults to `{ text: '' }` for the default block, and to `{}` — letting the tool apply its own defaults — whenever `toolName` names a different tool.
const parentIndex = editor.blocks.getBlockIndex('parent-block-id') ?? 0;
const child = editor.blocks.insertInsideParent(
'parent-block-id',
parentIndex + 1,
{ text: 'Nested content' },
);
// A TYPED child in the same single undo entry — no insert-then-reparent
editor.blocks.insertInsideParent(
'parent-block-id',
parentIndex + 1,
{ text: 'Nested heading', level: 2 },
'header',
);blocks.getBlocksCount()
numberGet the total number of blocks in the editor.
When to use
Counts every block including nested children (columns, toggles, table cells), not just top-level blocks. Use it for bounds before an index-based insert() or move().
const count = editor.blocks.getBlocksCount();
console.log('Total blocks:', count);blocks.insert(type?, data?, config?, index?, needToFocus?, replace?, id?, tunes?, origin?)
BlockAPIInsert a new block with full control over its properties and position.
When to use
Every parameter is optional — call insert() for an empty paragraph, or pass type/data/index for full control. Pass replace: true to overwrite the block at index.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
type | string | — | config.defaultBlock | Tool name to instantiate. |
data | BlockToolData | — | {} | Initial tool data for the new block. |
config | ToolConfig | — | {} | Ignored — accepted only to keep the positional signature stable. The editor binds it to _config and never reads it, and the block-manager insert options carry no config field, so the created block still uses the editor-level tool config from config.tools. Pass undefined. |
index | number | — | current block index + 1 | Position to insert the block at. |
needToFocus | boolean | — | true | Whether to move focus to the inserted block. Pass false to insert without moving the caret. |
replace | boolean | — | false | Replace the existing block at index instead of inserting around it. |
id | string | — | auto-generated | Custom id for the new block. |
tunes | { [name: string]: BlockTuneData } | — | undefined | Optional tune data applied at creation, keyed by tune name. |
origin | BlockOrigin | — | 'api' | Why the block is being created. Handed to the tool constructor as origin, which is how a container tool tells a genuine creation (seed default children) from a re-materialisation such as a document load, an undo/redo replay or a paste (never seed). Pass 'user' when the insert comes from your own insertion UI — a custom toolbar, slash menu or keyboard shortcut — so Blok's own containers (column_list, column) and your own behave the same as they do for the built-in menu. Leave it out for programmatic inserts and refetches: the 'api' default is still a creation, but it is never mistaken for a user gesture. |
Errors
No
typeis given and nodefaultBlockis configured.Could not insert Block. Tool name is not specified.
Pass an explicit
type, or setdefaultBlockin the editor config.The resolved tool name is not registered in the editor.
Could not compose Block. Tool «<type>» not found.
Register the tool in the editor's
toolsconfig before inserting a block of that type.replace: trueis passed but no block exists atindex.Could not replace Block at index <index>. Block not found.
Check
indexagainstblocks.getBlocksCount()before calling withreplace: true.
// Insert after the current block with the default type
const block = editor.blocks.insert();
// → BlockAPI { id: 'kP3xQ...', name: 'paragraph', ... }
// Insert paragraph with data at index 0
const block = editor.blocks.insert('paragraph', { text: 'Hello' }, undefined, 0);
// Insert with custom ID
const block = editor.blocks.insert('header', { text: 'Title' }, undefined, undefined, undefined, undefined, 'custom-id');
// From your own slash menu / toolbar: declare the user gesture so a container
// tool seeds its default children (see BlockOrigin on the tool contract)
const block = editor.blocks.insert('column_list', undefined, undefined, index, undefined, undefined, undefined, undefined, 'user');blocks.insertMany(blocks, index?)
BlockAPI[]Insert multiple blocks at once. When the index is omitted, the blocks are appended at the end of the document.
When to use
Bulk-insert in one operation (and one undo step) — far cheaper than looping insert() for large pastes or imports.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
blocks | OutputBlockData[] | LooseOutputBlockData[] | Required | — | The blocks to insert. Loose wire blocks are accepted — a null data becomes {}, a null/empty id gets a generated one. |
index | number | — | end of document | Position to insert at. When omitted, defaults to appending at the end of the document. |
Errors
The provided
indexis negative.Index should be greater than or equal to 0
Pass an
indexof 0 or greater, or omit it to append.
const blocksToInsert = [
{ id: '1', type: 'paragraph', data: { text: 'First' } },
{ id: '2', type: 'paragraph', data: { text: 'Second' } }
];
const inserted = editor.blocks.insertMany(blocksToInsert, 0);
console.log('Inserted:', inserted.length, 'blocks');blocks.composeBlockData(toolName)
Promise<BlockToolData>Create empty block data for the specified tool type.
When to use
Builds a tool's default data without inserting anything — useful when you need a valid empty payload for insert() or update().
Errors
toolNameis not a registered tool.Block Tool with type "<toolName>" not found
Register the tool in the editor's
toolsconfig first.
const emptyData = await editor.blocks.composeBlockData('paragraph');
// Returns: { text: '' } or appropriate empty state for the toolblocks.update(id, data?, tunes?)
Promise<BlockAPI>Update a block's data and/or tunes. When the block's tool implements `setData(newData)` on its prototype — every React/Vue/Angular block does, as do the built-in header, list, code, toggle and table tools — the data update is applied IN PLACE: the same block instance, the same DOM holder, the same mounted component keep living, so ephemeral tool state, adopted child blocks and the caret all survive. That is what makes `update()` safe to call on every keystroke (renaming a card while the user types). Tools without `setData`, a tool whose `setData` returns `false` (it needs a different DOM shape), and any call that passes `tunes` fall back to recomposing the block: a fresh tool instance replaces the old one, which is destroyed.
When to use
Patches data/tunes in place, keeping the block id and type. To change the type, use convert() instead.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Required | — | Id of the block to update. |
data | Partial<BlockToolData> | — | undefined | Partial data merged into the block's existing data. |
tunes | Record<string, BlockTuneData> | — | undefined | Tune data merged into the block's existing tunes. |
Errors
No block exists with the given
id.Block with id "<id>" not found
Confirm the id with
blocks.getById()before callingupdate().
// Update block data
const block = await editor.blocks.update('block-123', { text: 'New text' });
// Update with tunes
const block = await editor.blocks.update('block-123', undefined, { alignment: 'center' });
// Guard against an id that no longer exists (e.g. the block was deleted
// concurrently) instead of letting the rejection surface unhandled
try {
await editor.blocks.update(staleId, { text: 'New text' });
} catch {
console.warn('Block was already removed, skipping update');
}blocks.convert(id, newType, dataOverrides?)
Promise<BlockAPI>Convert a block to a different type. Both tools must support conversion config.
When to use
Both the source and target tools must declare conversionConfig, otherwise the call throws. Preserves text where possible.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Required | — | Id of the block to convert. Its tool must declare conversionConfig.export. |
newType | string | Required | — | Name of the registered tool to convert to. Its tool must declare conversionConfig.import. |
dataOverrides | BlockToolData | — | undefined | Data fields to overwrite on the resulting block after conversion. |
Errors
No block exists with the given
id.Block with id "<id>" not found
Confirm the id with
blocks.getById()before callingconvert().newTypeis not a registered tool.Block Tool with type "<newType>" not found
Register the target tool in the editor's
toolsconfig.The source tool has no conversionConfig.export, the target tool has no conversionConfig.import, or neither does.
Conversion from "<sourceType>" to "<newType>" is not possible. <ToolName(s)> tool(s) should provide a "conversionConfig"
Add a conversionConfig to whichever tool is missing one, or convert through an intermediate tool that supports both directions.
// Convert paragraph to header
const headerBlock = await editor.blocks.convert('block-123', 'header', { level: 2 });
// Convert with data overrides
const headerBlock = await editor.blocks.convert('block-123', 'header', { text: 'New Title', level: 1 });
// Not every pair of tools supports conversion — guard it rather than
// assuming the target type is always convertible
try {
await editor.blocks.convert('block-123', 'table');
} catch (error) {
console.warn('Conversion not supported between these tools:', error);
}blocks.splitBlock(currentBlockId, currentBlockData, newBlockType, newBlockData, insertIndex)
BlockAPIAtomically split a block by updating the current block and inserting a new block. Both operations are grouped into a single undo entry.
When to use
Use for caret-aware splits (e.g. Enter mid-text); the update + insert land as a single undo entry.
// Split a paragraph at cursor position
const newBlock = editor.blocks.splitBlock(
'current-block-id',
{ text: 'First part' },
'paragraph',
{ text: 'Second part' },
1
);blocks.stopBlockMutationWatching(index)
voidStop mutation watching on a block at the specified index. Use this to prevent spurious block-changed events during block replacement operations.
When to use
Niche: call before programmatically replacing a block's DOM to suppress spurious block-changed events; the next render re-arms it.
// Replace a block without triggering change events
editor.blocks.stopBlockMutationWatching(0);
// Perform block replacement...
// Mutation observer will not fire for this blockblocks.startBlockMutationWatching(blockId)
voidRe-arm mutation watching on a block previously silenced by `stopBlockMutationWatching`. It takes an **id**, not an index, because inserts and replacements between the two calls shift indexes; an id that no longer exists is silently skipped — a block replaced in place was constructed with its own watcher.
const blockId = editor.blocks.getBlockByIndex(0)?.id;
editor.blocks.stopBlockMutationWatching(0);
// Perform block replacement...
if (blockId) {
editor.blocks.startBlockMutationWatching(blockId);
}blocks.transact(fn)
voidGroup every block operation performed inside `fn` into a single undo entry. `fn` must be SYNCHRONOUS — operations that land after an await are no longer part of the group. Use it so structural edits are not partially undoable.
editor.blocks.transact(() => {
editor.blocks.insert('paragraph', { text: 'One' });
editor.blocks.insert('paragraph', { text: 'Two' });
});
// A single Cmd+Z removes both blocksblocks.beginTransaction()
voidOpen an undo group that stays open across async boundaries. Every block operation until `endTransaction()` lands in one undo entry. Use it for pointer gestures that mutate the document continuously (dragging a table's corner to add rows), where `transact()` cannot help because it only wraps a synchronous function. Every call must be paired with `endTransaction()`.
editor.blocks.beginTransaction();
// ... continuous mutations across async boundaries ...
editor.blocks.endTransaction();blocks.endTransaction()
voidClose the undo group opened by `beginTransaction()`.
editor.blocks.beginTransaction();
// ... continuous mutations across async boundaries ...
editor.blocks.endTransaction();blocks.transactWithoutCapture(fn)
voidRun block operations without recording anything in the undo history. Use it for auto-repair and normalization (e.g. ensuring an empty cell always has a block) that Cmd+Z should never step through.
editor.blocks.transactWithoutCapture(() => {
editor.blocks.insert('paragraph', {}, undefined, 0);
});
// Nothing was added to the undo historyblocks.setPointerDragActive(active)
voidTell core that a pointer drag interaction started or ended. While it is active, DOM-mutation-triggered Yjs syncs are suppressed so browser DOM churn during the drag cannot corrupt Yjs state.
editor.blocks.setPointerDragActive(true);
// ... run the drag gesture ...
editor.blocks.setPointerDragActive(false);Properties
| Property | Type | Description |
|---|---|---|
isSyncingFromYjs | boolean | Readonly getter — true while a Yjs sync operation (undo/redo) is in progress. Tools read it to skip cleanup that would fight undo state. Note this is a PROPERTY on `editor.blocks`, unlike the React hook's `isSyncingFromYjs()` method. |
isPointerDragActive | boolean | Readonly getter — true while a pointer drag interaction is active. Framework adapters read it to defer a programmatic `dispatchChange` mid-drag (core silently drops such a change) and re-dispatch it once the drag ends. |