---
title: "Blok Configuration — holder, tools, data, i18n"
description: "Every configuration option: holder, tools, data, placeholder, readOnly, i18n, and the style tokens."
source: https://blokeditor.com/docs/config/
lastmod: 2026-10-07
---

Framework JavaScript

Core Configuration

On this page holder

# Blok configuration options

The configuration object passed to the Blok constructor. It is formally split into two types.

- `BlokMountOptions` holds the options that are fixed for the life of the instance (holder, tools, i18n, and so on).
- `BlokState` holds the LIVE fields: `readOnly` (including `hideControls`), `hideToolbar`, `toolbarPosition`, `inlineToolbar`, and the editor callbacks `onChange`, `onSave`, `onEnter`, `onSubmit`, `onBeforeRender` and `onAfterRender`.

Every `BlokState` field maps to a documented runtime setter (`readOnly.set`, `toolbar.setHidden`, `toolbar.setPosition`, `tools.setInlineToolbar`, `handlers.set`). Changing one never requires recreating the editor. The React, Vue and Angular adapters react to these props and inputs in place.

Callback PRESENCE is itself load-bearing: an `onSubmit` turns Enter into serialize-and-submit, and an `onSave` arms the change pipeline. That is why `handlers.set` also accepts `undefined`, to unset one.

`BlokConfig = BlokMountOptions & BlokState`, so existing code compiles unchanged.

Last updated Oct 7, 2026 [Edit this page on GitHub](https://github.com/JackUait/blok/blob/main/docs/src/components/api/api-data.ts)

TypeScript

```
import { Blok, type BlokConfig } from '@bloklabs/core';

const config: BlokConfig = {
  holder: 'editor',
  placeholder: 'Start writing...',
  autofocus: true,
  readOnly: false,
  minHeight: 300,
};

const editor = new Blok(config);
```

## Configuration

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `holder` | `string | HTMLElement` | `'blok'` | Container element ID or reference |
| `tools` | `Record<string, ToolConstructable | ToolSettings>` | `{}` | Available block and inline tools. Nothing is registered by default. The `{}` default leaves only Blok's internal tools (`stub`, `delete`, `copyLink`, `convertTo`), so a bare `new Blok({ holder })` cannot render even a paragraph. Two ready-made bundles are exported from `@bloklabs/core/full`. `defaultTools` holds paragraph, header and list, all with `inlineToolbar: true`. `allTools` holds `defaultTools` plus quote, callout, code, toggle and every inline tool. Setting `toolbox: false` on a tool keeps it registered, so existing blocks still render and blocks.insert() still works. It removes the tool from every user-insertion path: the + / slash menu, the convert menu, and its keyboard shortcut. That is useful for permission gating. You can flip it at runtime via `tools.update(name, { toolbox })`. The React adapter applies changes to the `tools` prop's `toolbox` values automatically. So a permission change never requires recreating the editor. |
| `tunes` | `string[]` | `undefined` | Names of block tunes added to every block tool that does not declare its own `tunes` set. The tune classes themselves must be registered in `tools` (a class with `static isTune = true`). |
| `placeholder` | `string | false` | `false` | Placeholder text handed to every block of the default tool. It reaches every block, not only the first, and not only while the document is empty. With the built-in paragraph it is visible whenever a block is empty and focused. Note that `false` (also the default) does not remove the placeholder. The paragraph tool then falls back to its own built-in localized text ("Write something or press / to select a tool"). To blank it, give the default tool an empty placeholder of its own: `tools: { paragraph: { class: Paragraph, placeholder: '' } }`. |
| `minHeight` | `number` | `300` | Height in px of the editor's bottom clickable zone |
| `captureClicksBelowEditor` | `boolean` | `false` | Opt-in: clicks on the host page below the editor append a block, with zero layout footprint. Pair it with `minHeight: 0` to remove the bottom zone entirely. Only clicks landing on the empty background of an element that contains the editor count. Clicks on your own content rendered below are ignored. Propagation is never stopped, so host click handlers keep working alongside. |
| `inlineEmoji` | `boolean` | `true` | Inline emoji menu. Typing `:` followed by a name in a text block opens it, for example `:fire`. Picking an emoji replaces the typed `:query` with the character. The colon opens the menu only at the start of a word. That means at the start of the block, or right after a space. `10:30`, `http://` and `Note: this` do not open it. At least one character must follow the colon. Typing a closing `:` can insert the emoji right away. That only happens when the query is an exact shortcode. `:fire:` gives 🔥 without the menu ever opening. Any other query is left as typed text. Escape closes the menu. It leaves the typed text unchanged. The page does not scroll while the menu is open. The menu's own grid still scrolls. The menu has no search field, no random button, and no remove button. It also has no curated section. Those stay on the Callout tool's own emoji picker, which this option does not affect. `false` turns the menu off. An absent key keeps it on. |
| `find` | `boolean | FindConfig` | `true` | Find in page. Cmd/Ctrl+F inside the editor opens Blok's own find bar instead of the browser's. The bar opens at the top right of the window, where browsers put their own find bar, and stays above everything on the page. If you select short, single-line text in the editor or a host field, such as a page title, the bar starts searching for it. Pass an object to choose where the bar sits, for example `find: { placement: 'bottom-center', offset: { y: 24 } }`. `placement` is `top-end` (the default), `top-start`, `top-center`, `bottom-end`, `bottom-start` or `bottom-center`. `start` and `end` follow the editor's text direction. `offset` is the distance in pixels from those window edges. Use it to clear your own fixed UI, such as a header. The bar is fixed there: people using the editor cannot move it. Matching is loose by default. Case is ignored. The `Aa` button (Alt+C) turns on match case. Accents are ignored, so `cafe` finds `café`. Curly and straight quotes match each other. So do the different dashes. A run of spaces matches a single space. The `ab` button (Alt+W) keeps only whole-word matches. A match can run across bold, italic and links. It never spans two blocks or two table cells. Search covers text across the host page and all Blok editors, including read-only content, code blocks, table cells and the children of collapsed toggles. Enter and Shift+Enter move to the next and previous match, and so do the Down and Up arrows in the search field. Cmd/Ctrl+G and F3 do the same, and Shift makes them go back. A counter shows your place, such as `3 of 12`. A strip under the field marks where each match sits on the page. Click it to jump there. Typing never opens a collapsed toggle, but the matches inside it still count. Moving to one of them opens its toggle. That goes for the keys, the arrow buttons and the match map. In an editable editor this is a normal change, the same as clicking the toggle's arrow. It is saved and can be undone. Escape closes the bar and selects the current match only when it is editable text in this editor. Typing then replaces it. Pressing Cmd/Ctrl+F again while the bar has focus selects the search text. It does not open the browser's find. Open replace with the chevron button, with Cmd+Option+F on macOS, or with Ctrl+H elsewhere. In the replace field, Enter replaces the current match if it is in this editor, then moves to the next one. Cmd/Ctrl+Enter replaces every editable match in this editor as a single undo step. Text elsewhere on the page is never replaced. Replace is not available in read-only mode. While you type a replacement, editable text in this editor shows the result before you replace. Each match is struck out, with the replacement right after it, and lines wrap the way they will. The preview is not saved, is not part of undo, and cannot be clicked. Matches are painted with the CSS Custom Highlight API. No markup is added to your content, so finding never changes the document. In a browser without that API the matches get no fill. The ring around the current match still shows, and stepping still works. The colors are theme tokens, listed in the Styles API section. Cmd/Ctrl+F in your own page's fields, such as a page title or a search box, opens Blok's find too, and so does Cmd/Ctrl+F with nothing focused. A field that handles Cmd/Ctrl+F itself and calls `preventDefault()` keeps it. With several editors, it goes to the one used last. `false` leaves Cmd/Ctrl+F to the browser everywhere. An absent key keeps find on. |
| `defaultBlock` | `string` | `'paragraph'` | Default block type |
| `data` | `OutputData | LooseOutputData | null` | `undefined` | Initial data to render. The loose wire shape is accepted: `null` values for block `data`, `id`, or `time` (common in backend DTOs) are normalized at the boundary. A whole-document `null` is also accepted and normalized to an empty document. So you can pass nullable controlled state straight through, without a `value ?? { blocks: [] }` guard. |
| `dataModel` | `'legacy' | 'hierarchical' | 'auto'` | `'auto'` | Input/output data model. 'auto' detects the format of the data you render and preserves it on save. 'legacy' always uses the nested `items[]` structure. 'hierarchical' always uses flat blocks with `parent`/`content` references. |
| `sanitizer` | `SanitizerConfig` | `{}` | Editor-wide default sanitizer allowlist. Blok composes it with each tool's own `sanitize` rules. It applies on save, on render, on paste and on copy of selected blocks. |
| `readOnly` | `boolean | { hideControls?: boolean }` | `false` | Enable read-only mode. Pass `{ hideControls: true }` to also hide the hover toolbar, block settings, and inline toolbar. Live field: change it at runtime via `readOnly.set(state, { hideControls })`. The same instance flips modes in place, preserving caret, undo history and scroll. |
| `onChange` | `(api: API, event: BlockMutationEvent | BlockMutationEvent[]) => void` | `undefined` | Change callback function. The event argument carries the mutation that occurred, or an array of them when several fire at once. Latency is bounded, so it is safe to drive UI from. The first change of an idle document arrives on the next microtask, in the same frame the user typed in. The changes after it are coalesced into one further call at the end of a short batch window, and later changes never extend that window. Each event's `detail.origin` says who made the change. It is `'local'` for this tab, including its undo and redo, `'tab'` for another tab of this browser, and `'remote'` for a collaborator. Live field: install, replace or unset it at runtime via `handlers.set({ onChange })`. Its presence, together with `onSave`, is what arms Blok's change-observation pipeline at all. |
| `onSave` | `(data: OutputData, api: API) => void` | `undefined` | Reactive save callback. It fires automatically with the full serialized content on every batched content change, so you do not have to call save() by hand. It rides the trailing edge of the batch window only. Unlike onChange it never leads the window, because serializing the whole document is too expensive to front-run the batch with. Live field: install, replace or unset it at runtime via `handlers.set({ onSave })`. Its mere presence makes Blok serialize the document once per change batch. With tab sync, only the tab you are working in calls it. In background tabs, read the document with `editor.save()`. |
| `onReady` | `(blok?: Blok) => void` | `undefined` | Fires once when the editor becomes ready, receiving the fully-initialized Blok instance |
| `onEnter` | `(event: KeyboardEvent, api: API) => boolean | void` | `undefined` | Fires when Enter is pressed in a block, before Blok splits it or creates a new one. Return true to mark it handled, and Blok suppresses its default block split or create (the native newline is still prevented). It never fires for tools with enableLineBreaks, or while a popover or toolbar owns Enter, or for a soft-line-break Shift+Enter. The one exception is iOS: Safari reports Shift+Enter for a sentence-ending '. ', and Blok creates a block, so the hook fires there too. It suits chat inputs ("Enter sends"). Pair it with the paragraph tool's preserveBlank config instead of subclassing Paragraph. Live field: install, replace or unset it at runtime via `handlers.set({ onEnter })`. |
| `onSubmit` | `(data: OutputData, api: API) => void` | `undefined` | Fires with the full serialized OutputData on the Enter that would otherwise create or split a block, the "Enter sends" gesture. Blok serializes the document and suppresses the default split, so you do not wire save() into onEnter by hand. It inherits every onEnter escape. When both are set, an onEnter that returns true takes precedence and suppresses onSubmit. Live field: install, replace or unset it at runtime via `handlers.set({ onSubmit })`. Pass `undefined` to restore Blok's default Enter (split the block) without recreating the editor. |
| `onError` | `(error: Error, context: { source: 'save' }) => void` | `undefined` | Fires when an editor operation fails that Blok would otherwise only log. Today the sole source is serialization. Both the debounced auto-save and an explicit save() route through it. A failed save() rejects with the underlying error and never resolves with undefined, so wrap explicit saves in try/catch. onError additionally surfaces failures of the debounced auto-save, which has no promise of its own. |
| `onBeforePaste` | `(html: string) => string | null` | `undefined` | Transforms the raw `text/html` clipboard payload before any Blok preprocessing or sanitization, so you no longer need a capture-phase paste interceptor. Return the HTML to feed into the rest of the paste pipeline. Return null to skip the HTML path and fall through to plain text. Everything below runs after your hook. Blok normalizes what other apps put on the clipboard. Copy an answer out of ChatGPT, Claude or Gemini, or a page out of Notion or Google Docs. It arrives as real blocks, not one flat paragraph. That means headings, lists, tables, quotes and code. ChatGPT and Gemini get a dedicated pre-pass on top, because each hides meaning in markup the sanitizer would otherwise drop. ChatGPT ships no MathML. So a formula's LaTeX is recovered from its source attribute and rebuilt as an equation. Its code blocks are also de-duplicated. Each one renders as a nested editor. Gemini's code language is read off the label it prints above the block. Claude has no pre-pass of its own: its answers are already semantic HTML, so they come through the standard HTML and markdown paths. |
| `onBeforeRender` | `(blocks: OutputBlockData[]) => OutputBlockData[]` | `undefined` | Transforms the blocks array just before it is rendered. It runs on the initial render, on every `blocks.render()` call, and on the repaints Blok performs itself. Those repaints are a runtime `i18n.update()` and the read-only fallback re-render. It receives the raw saved blocks, before format analysis or hierarchical expansion, and returns the blocks to render. That way app-specific data migrations run inside Blok instead of ahead of it. It must therefore be idempotent: those repaints feed it blocks it has already transformed. Live field: install, replace or unset it at runtime via `handlers.set({ onBeforeRender })`. |
| `onAfterRender` | `(api: API) => void` | `undefined` | Fires after each render batch lands in the DOM: the initial render, every `blocks.render()`, and the repaints Blok performs itself. Those are a runtime `i18n.update()` locale or messages change, and a `readOnly.set()` toggle that falls back to a full re-render because a mounted tool does not support in-place read-only. Use it for post-render side effects such as scroll restoration or attaching observers. Keep those extra triggers in mind if you count renders. Distinct from onReady, which fires once when the editor first becomes ready. Live field: install, replace or unset it at runtime via `handlers.set({ onAfterRender })`. |
| `autofocus` | `boolean` | `false` | If true, sets the caret in the first block once the editor is ready |
| `scrollToBlock` | `{ topOffset?: number }` | `undefined` | Blok always smooth-scrolls to the block whose id matches the page URL hash (`#<blockId>`) once blocks are rendered. That includes blocks rendered later via `blocks.render()`. This option only tunes that behavior: `topOffset` (default 0) reserves space above the block for a sticky header. |
| `inlineToolbar` | `string[] | boolean` | `true` | Default inline toolbar for all tools. An array restricts it to the listed inline tools, and false disables it. The array picks which tools appear, never where. The toolbar always renders the built-ins in one fixed order (convert, color, bold, italic, underline, clear formatting, link, strikethrough, inline code, equation, sup/sub). Any custom inline tool is appended after them, in registration order. Live field: reconfigure it at runtime via `tools.setInlineToolbar(config)`. |
| `hideToolbar` | `boolean` | `false` | Hide the hover block toolbar (plus button / drag handle) and collapse the editor gutter reserved for it. The keyboard "/" menu keeps working. Live field: flip it at runtime via `toolbar.setHidden(hidden)`. |
| `toolbarPosition` | `'left' | 'right'` | `'left'` | Which side of the content column the floating block controls (plus button and drag/settings handle) occupy. `'right'` moves both the controls and the gutter reserved for them to the editor's inline-end side. The start gutter collapses and an equal one opens at the end, so the text reclaims the space the controls used to occupy. The values name the LTR-physical side and are applied through logical properties, so an RTL editor mirrors them. No effect while `hideToolbar` is on, or in chromeless read-only, because there are no controls to place. Live field: move them at runtime via `toolbar.setPosition(position)`. |
| `i18n` | `I18nConfig` | `undefined` | Internationalization config (locale plus message dictionary). Live field: switch language at runtime via `i18n.update({ locale, messages })`. The editor relabels in place, so caret and undo history survive a language switch. `defaultLocale` is the exception and stays mount-only. Custom tool titles are localizable by registration name, for example a `fileLink` tool via `messages: { 'toolNames.fileLink': '…' }`, or via a `titleKey` in the tool's toolbox entry. |
| `uploader` | `BlokUploader` | `undefined` | Editor-level uploader for every media asset, routed by asset KIND rather than by tool. `uploadByFile(file, { kind, tool })` and `uploadByUrl(url, { kind, tool })` receive `kind: 'image' | 'video' | 'audio' | 'file'`, so one implementation serves the image, video, audio and file blocks. That includes assets a tool owns outside its own media family, such as the audio block's cover art (`kind: 'image'`, `tool: 'audio'`), which has no tool-level uploader of its own. A tool-level uploader (`tools.image.config.uploader`) stays authoritative for its own kind and takes precedence. This one is the fallback. Without either, assets become `blob:` URLs that do not survive a reload. |
| `media` | `MediaConfig` | `undefined` | Convert every uploaded photo into several formats. The editor then shows the best format each browser supports. It is off until you list formats. `formats.image` takes `'avif'`, `'webp'`, `'jpeg'` and `'png'`. The order you write does not matter. Blok stores and renders the formats best first. The most compatible format becomes `url`, so readers that ignore `variants` still work. A format the browser cannot encode is skipped. When no JPEG or PNG was made, the original file is kept as the fallback. A transparent image never gets a JPEG. Each format is a separate upload. Your uploader receives `variant: { mimeType, role }` in its context. `convert(file, formats, ctx)` replaces the built-in converter. Return `null` to use Blok's own. Videos work differently, because converting them takes time. `formats.video` takes `'av1'`, `'webm'` and `'mp4'`. The original plays at once. Each format is made in the background, most compatible first, and added when it is ready. Once an MP4 exists it becomes `url`. Until then the original stays as `url`. While a video converts, closing the page shows the browser's "Leave site?" dialog. Videos longer than `maxTranscodeDuration` seconds (default 600) are only remuxed, never re-encoded. Blok does not bundle the video converter, because it is MPL-2.0. Install `mediabunny` and pass `mediabunny: () => import('mediabunny')`. Without it, video formats come only from `convert`. |
| `server` | `string` | `undefined` | Base URL of a service speaking Blok's upload and unfurl contracts, such as `https://blok.myapp.com`, or a same-origin path like `/api/blok`. It is shorthand only. It fills in `uploader`, the bookmark tool's `endpoint` and `link.unfurl` when you have not set them yourself, and anything you set explicitly wins. That is what lets you take the service for link previews while uploading into your own S3, with no bridging code. It does not configure document storage. Your documents stay yours. See `persistence`. |
| `ticket` | `string` | `undefined` | Endpoint in YOUR app that mints a short-lived access pass for the signed-in user, answering `{ "ticket": "<pass>" }`. You need it whenever the routes check passes: a standalone service run with `--auth ticket`, or routes inside your own app run with `Auth = "ticket"`. With `Auth` set to `none` or `proxy`, no pass is read. The editor caches the pass and replaces it ahead of expiry rather than at it, so no request arrives already invalid. Uploads and link previews share the same pass. `@bloklabs/server/ticket` exports `blokTicket()` for minting it, and a .NET backend uses `BlokTicket.Create` from `Blok.Server`. Any other backend can sign one with its own JWT library. The header must then be exactly `{"alg":"HS256","typ":"JWT"}`, because the server compares it byte for byte. |
| `persistence` | `{ load(): Promise<OutputData | PersistedDocument | null>; save(data: OutputData, ctx: SaveContext): Promise<SaveResult | void>; onError?(error: unknown): void }` | `undefined` | Load the document on mount and save it as it changes, against your own endpoint. The Blok service stores no documents. It takes two callbacks rather than a URL, because the endpoint shape, its auth and the document id are yours. Saves never run in parallel, and only the newest pending document follows the one in flight. So a slow save finishing after a fast one cannot bring stale content back. Loading only happens when you passed no `data`, and setting `onSave` yourself wins. `load` may answer with a version alongside the document. Each `save` is told the version it is overwriting and may report the one it wrote. Blok only carries that version between the two calls, so your endpoint stays the only place a stale write is detected. |
| `collaboration` | `{ doc: string; user?: { name: string; color?: string }; offline?: boolean; offlineScope?: string }` | `undefined` | Real-time multiplayer editing against the sync service `server` points at. Two editors opened on the same `doc` see each other's edits live. `doc` is the shared document id, and it becomes one path segment of the sync URL. It must therefore be a single path segment: no `/`, no encoded slash, no `.` or `..`. Anything else is refused at construction rather than failing at the door. `user` is the DISPLAY identity the other people see. It is the name on their avatar, and the color of their cursor and of the small face parked in the margin beside the block they are in. It is also what the `user: { id }` option, which records edit attribution, gets NAMED by. Set both and peers can read "Last edited by <name>" on the blocks you edited, because the id rides into the presence state alongside that name. The name is published to the room. `user.name` is not, so a host that sets only `user.name` names itself and nobody else. Everyone in the room also sees when you were last active, alongside where your cursor is. Set neither and you are present as an anonymous avatar with no id published at all. `color` is HEX only (`#rgb`, `#rrggbb`, with or without alpha). Anything else is replaced with a color from the built-in palette. `offline` keeps a copy of the document in that browser, so edits made while disconnected survive a reload. It is off by default, because it writes document content into browser storage, and it is dropped whenever the service resets the document. It REQUIRES `offlineScope`, an opaque stable id for the signed-in account. Browser storage belongs to the browser rather than to a person, so without a partition the next person on a shared profile is handed the previous one's document. It is never an authorization claim, because the server never sees it, and it is never the display identity. Do not derive it from anything that rotates: a new partition on every refresh strands every copy before it. It requires `server`, and is mutually exclusive with `persistence`. The sync service owns the whole document round-trip, so a second load/save pair would give the document two owners. Both pairings are refused at construction. Absent, it costs nothing: Blok opens no socket. Mount-only: changing it means recreating the editor. React and Vue take it as a `collaboration` prop. Angular has no dedicated input, so it goes through `[config]`. Connection state and the people present arrive on the `collaboration:status` event. |
| `documentId` | `string` | `undefined` | Your app's id for this document. Tabs of one browser that show the same `documentId` stay in sync live, with no server. Use the id your app already loads and saves the document by, such as a route param or a record key. It must be unique across the whole site. An empty string is refused, and `isReady` rejects. Without it, Blok uses the `id` it writes into saved data, plus the page path. That only covers a document that `persistence` loads or saves. Mount-only: to show another document, recreate the editor. The Tab sync guide covers where the id comes from. |
| `tabSync` | `boolean | { settings?: boolean }` | `true` | Live sync between the tabs of one browser. It is on by default. `false` turns it off. `{ settings: false }` keeps document sync but stops syncing locale, theme mode and width. It never runs with `collaboration`, whose server already syncs every tab. Mount-only: changing it means recreating the editor. |
| `theme` | `'auto' | 'light' | 'dark'` | `'auto'` | Color theme; 'auto' follows the OS preference via prefers-color-scheme |
| `onThemeChange` | `(resolvedTheme: ResolvedTheme) => void` | `undefined` | Fires with the RESOLVED theme ('light' or 'dark') whenever it changes. That covers the OS preference flipping while `theme` is 'auto', and `theme.set()` changing what the theme resolves to. It does not fire on initialization, and it does not fire when a `theme.set()` leaves the resolved theme unchanged. It is a core config option, not an adapter-only prop. The framework adapters expose the same callback as the `onThemeChange` prop, the `theme-change` emit and the `themeChange` output. |
| `link` | `{ target?: string; rel?: string; transformHref?: (href: string) => string; transform?: (context: LinkTransformContext) => LinkTransformResult | void; unfurl?: { endpoint: string; headers?: Record<string, string> | (() => Promise<Record<string, string>>) } }` | `undefined` | Controls the anchors Blok creates, instead of post-processing the rendered DOM. It applies on every path that produces an `<a>`: the Link inline tool, `blocks.render()` (anchors coming from stored block HTML) and paste. `target` defaults to '_blank' and `rel` to 'nofollow'. `transform` is the superset and supersedes `transformHref`, which is then ignored. It receives the href, text and element, and may return `href`, `target`, `rel` and extra `attributes`. Omitted fields fall back to the shorthand defaults, including the same-page `_self` rule. Both must be idempotent: on the render and paste paths they re-run against already-transformed anchors on every render. `unfurl` feeds the Recent list in the Link field. That list shows the last 3 web links you added, by page title. `unfurl` takes `{ endpoint, headers? }`, the same contract as the bookmark tool's `endpoint`. Blok looks each title up once. `server` fills `unfurl` in for you. Without it, a recent link shows its site name. The list lives in the browser's localStorage under `blok-recent-links`. |
| `linkPaste` | `{ allowGenericEmbed?: boolean; allowedEmbedOrigins?: string[]; hostAliases?: string[] }` | `undefined` | Notion-style link-paste behavior. Set `allowGenericEmbed: true` to also offer "Create embed" (framed in a sandboxed iframe) for URLs that match no registered embed provider. The default keeps Blok's registry-only embed guarantee. `allowedEmbedOrigins` is the fine-grained middle ground: hostnames (`dashboards.example.com`) or wildcard subdomain patterns (`*.internal.example.dev`) that may be framed as generic embeds. A stored generic embed matching neither renders as a safe clickable link card instead of an iframe, so the URL stays visible without being framed. Paste a URL while text is selected, and the selected text becomes a link to it. No menu opens. `hostAliases` lists other hostnames (or `*.` wildcards) that count as your own site, next to the page's hostname. A pasted link to your own site never offers "Create bookmark". If no other choice is left, no menu opens. |
| `user` | `{ id: string; name?: string }` | `undefined` | Identity of the current editor. Blok stamps `user.id` onto the `lastEditedBy` of every block this user edits, and without it `lastEditedBy` stays null. Add `name` and the block settings footer reads "Last edited by <name>" with no callback to wire. That name is local only, so peers name you from `collaboration.user.name`. Set both to be named everywhere. For other people's ids Blok asks `resolveUser`, and in a collaborative room it also learns names from the peers themselves. The id is not private under collaboration: it goes into the shared document as `lastEditedBy`, and, only when `collaboration.user.name` is set too, into the presence state next to that name. That is how peers name each other. Pass an opaque per-document token instead of an internal account key if that matters. |
| `resolveUser` | `(id: string) => UserInfo | Promise<UserInfo | null> | null` | `undefined` | Resolves the `lastEditedBy` user id Blok shows in the block settings footer. It may return synchronously or asynchronously. Return null for an unknown user, and Blok falls back to the name that user published to the collaborative room, or to the date alone. Blok asks at most once per id: it keeps both an answer and an "I don't know this one" for the editor's lifetime. A callback that throws or rejects is absorbed rather than breaking the menu, and because that is not an answer, the id is asked about again later. Wiring this callback also means an unauthenticated name from the room is never shown while the lookup is still running. The id arrives trimmed and stripped of NUL. One longer than 128 characters is refused rather than passed on. |
| `notifierPosition` | `NotifierPosition` | `'bottom-center'` | Where the built-in toast container is anchored on screen. |
| `notifier` | `(options: NotifierOptions | ConfirmNotifierOptions | PromptNotifierOptions) => void` | `undefined` | Replaces the built-in toast entirely. Blok calls your handler with the same options object instead of rendering its own DOM notification. |
| `logLevel` | `LogLevels` | `LogLevels.VERBOSE` | How much Blok logs to the console. The values are `VERBOSE`, `INFO`, `WARN` and `ERROR`. There is no "silent" level, so `LogLevels.ERROR` is the quietest. `LogLevels` is a named export of the package root. |
