Skip to content
FrameworkJavaScript

Blok configuration options

The configuration object passed to the Blok constructor. It is formally split into two types: `BlokMountOptions` — options fixed for the instance's life (holder, tools, i18n, …) — and `BlokState`, 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`), so changing it never requires recreating the editor — and the React, Vue and Angular adapters react to these props/inputs in place. Callback PRESENCE is itself load-bearing (an `onSubmit` turns Enter into serialize-and-submit; an `onSave` arms the change pipeline), which is why `handlers.set` also accepts `undefined` to unset one. `BlokConfig = BlokMountOptions & BlokState`, so existing code compiles unchanged.

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

OptionTypeDefaultDescription
holderstring | HTMLElement'blok'Container element ID or reference
toolsRecord<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` (paragraph, header and list, all with `inlineToolbar: true`) and `allTools` (`defaultTools` plus quote, callout, code, toggle and every inline tool). Per-tool `toolbox: false` keeps a tool registered (existing blocks still render, blocks.insert() still works) while removing it from every user-insertion path — the + / slash menu, the convert menu, and its keyboard shortcut. Useful for permission gating — and flippable 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.
tunesstring[]undefinedNames 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`).
placeholderstring | falsefalsePlaceholder text handed to every block of the default tool — not only the first block, 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: '' } }`.
minHeightnumber300Height in px of the editor's bottom clickable zone
captureClicksBelowEditorbooleanfalseOpt-in: clicks on the host page below the editor append a block, with zero layout footprint. Pair 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, and propagation is never stopped, so host click handlers keep working alongside.
defaultBlockstring'paragraph'Default block type
dataOutputData | LooseOutputData | nullundefinedInitial 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 nullable controlled state can be passed 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.
sanitizerSanitizerConfig{}Editor-wide default sanitizer allowlist. Composed with each tool's own `sanitize` rules and applied on save, on render, on paste and on copy of selected blocks.
readOnlyboolean | { hideControls?: boolean }falseEnable read-only mode. Pass `{ hideControls: true }` to also hide the hover toolbar, block settings, and inline toolbar. Live: change 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[]) => voidundefinedChange callback function; the event argument carries the mutation(s) that occurred (batched into an array 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 — the same frame the user typed in — and the changes after it are coalesced into one further call at the end of a short batch window that later changes never extend. Live: 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) => voidundefinedReactive save callback — fires automatically with the full serialized content on every batched content change, so you don't have to call save() by hand. It rides the trailing edge of the batch window only — unlike onChange it never leads it, because serializing the whole document is too expensive to front-run the batch with. Live: install, replace or unset it at runtime via `handlers.set({ onSave })` — its mere presence makes Blok serialize the document once per change batch.
onReady(blok?: Blok) => voidundefinedFires once when the editor becomes ready, receiving the fully-initialized Blok instance
onEnter(event: KeyboardEvent, api: API) => boolean | voidundefinedFires when Enter is pressed in a block, before Blok splits it or creates a new one. Return true to mark it handled — Blok suppresses its default block split/create (the native newline is still prevented). Never fires for tools with enableLineBreaks or while a popover/toolbar owns Enter, and not for a soft-line-break Shift+Enter — except on iOS, where Safari reports Shift+Enter for a sentence-ending '. ' and Blok creates a block, so the hook fires there too. Ideal for chat inputs ("Enter sends") — pair with the paragraph tool's preserveBlank config instead of subclassing Paragraph. Live: install, replace or unset it at runtime via `handlers.set({ onEnter })`.
onSubmit(data: OutputData, api: API) => voidundefinedFires 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 don't 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: 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' }) => voidundefinedFires 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 — it 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 | nullundefinedTransforms the raw `text/html` clipboard payload before any Blok preprocessing or sanitization, so a capture-phase paste interceptor is no longer needed. Return the HTML to feed into the rest of the paste pipeline, or 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, so an answer copied out of ChatGPT, Claude or Gemini — or a page copied out of Notion or Google Docs — arrives as real blocks (headings, lists, tables, quotes, code) rather than one flat paragraph. 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, and its code blocks are 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[]undefinedTransforms the blocks array just before it is rendered — on the initial render, on every `blocks.render()` call, and on the repaints Blok performs itself (a runtime `i18n.update()`, and the read-only fallback re-render). Receives the raw saved blocks (before format analysis or hierarchical expansion) and returns the blocks to render, so 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: install, replace or unset it at runtime via `handlers.set({ onBeforeRender })`.
onAfterRender(api: API) => voidundefinedFires after each render batch lands in the DOM: the initial render, every `blocks.render()`, and the repaints Blok performs itself — a runtime `i18n.update()` locale/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 (scroll restoration, attaching observers), keeping in mind those extra triggers if you count renders. Distinct from onReady, which fires once when the editor first becomes ready. Live: install, replace or unset it at runtime via `handlers.set({ onAfterRender })`.
autofocusbooleanfalseIf true, sets the caret in the first block once the editor is ready
scrollToBlock{ topOffset?: number }undefinedBlok always smooth-scrolls to the block whose id matches the page URL hash (`#<blockId>`) once blocks are rendered — including blocks rendered later via `blocks.render()`. This option only tunes that behavior: `topOffset` (default 0) reserves space above the block for a sticky header.
inlineToolbarstring[] | booleantrueDefault inline toolbar for all tools; an array restricts it to the listed inline tools, false disables it. Live: reconfigure at runtime via `tools.setInlineToolbar(config)`.
hideToolbarbooleanfalseHide the hover block toolbar (plus button / drag handle) and collapse the editor gutter reserved for it; the keyboard "/" menu keeps working. Live: flip 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 — there are no controls to place. Live: move at runtime via `toolbar.setPosition(position)`.
i18nI18nConfigundefinedInternationalization config (locale + message dictionary). Live: 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 — e.g. a `fileLink` tool via `messages: { 'toolNames.fileLink': '…' }` — or via a `titleKey` in the tool's toolbox entry.
uploaderBlokUploaderundefinedEditor-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 — including 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 is the fallback. Without either, assets become `blob:` URLs that do not survive a reload.
serverstringundefinedBase URL of a service speaking Blok's upload and unfurl contracts — `https://blok.myapp.com`, or a same-origin path like `/api/blok`. Shorthand only: it fills in `uploader` and the bookmark tool's `endpoint` 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`.
ticketstringundefinedEndpoint in YOUR app that mints a short-lived access pass for the signed-in user, answering `{ "ticket": "<pass>" }`. Only needed when `server` points at a standalone service — routes running inside your own app already know who the caller is. The editor caches the pass and replaces it ahead of expiry rather than at it, so no request arrives already invalid, and uploads and link previews share the same one. `@bloklabs/server/ticket` exports `blokTicket()` for minting it; any backend can do the same with its own JWT library.
persistence{ load(): Promise<OutputData | PersistedDocument | null>; save(data: OutputData, ctx: SaveContext): Promise<SaveResult | void>; onError?(error: unknown): void }undefinedLoad the document on mount and save it as it changes, against your own endpoint — the Blok service stores no documents. 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, and 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 }undefinedReal-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 — so it must be a single path segment (no `/`, no encoded slash, no `.`/`..`), and anything else is refused at construction rather than failing at the door. `user` is the DISPLAY identity the other people see — 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 — and is independent of the `user: { id }` option, which records edit attribution: one answers "whose cursor is that", the other "who gets the credit for this edit"; set either, both or neither. `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 — 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 — the server never sees it — and never the display identity; do not derive it from anything that rotates, because a new partition on every refresh strands every copy before 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 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.
theme'auto' | 'light' | 'dark''auto'Color theme; 'auto' follows the OS preference via prefers-color-scheme
onThemeChange(resolvedTheme: ResolvedTheme) => voidundefinedFires with the RESOLVED theme ('light' or 'dark') whenever it changes — both when the OS preference flips while `theme` is 'auto', and when `theme.set()` changes 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. A core config option, not an adapter-only prop; the framework adapters expose the same callback as the `onThemeChange` prop / `theme-change` emit / `themeChange` output.
linkPaste{ allowGenericEmbed?: boolean; allowedEmbedOrigins?: string[] }undefinedNotion-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.
user{ id: string }undefinedIdentity of the current editor. Blok stamps `user.id` onto the `lastEditedBy` of every block this user edits — without it `lastEditedBy` stays null. Pair it with `resolveUser` to render a name in the block settings footer.
resolveUser(id: string) => UserInfo | Promise<UserInfo | null> | nullundefinedResolves the `lastEditedBy` user id Blok shows in the block settings footer. May return synchronously or asynchronously; return null for an unknown user and Blok falls back to showing the date only.
notifierPositionNotifierPosition'bottom-center'Where the built-in toast container is anchored on screen.
notifier(options: NotifierOptions | ConfirmNotifierOptions | PromptNotifierOptions) => voidundefinedReplaces the built-in toast entirely — Blok calls your handler with the same options object instead of rendering its own DOM notification.
logLevelLogLevelsLogLevels.VERBOSEHow much Blok logs to the console. 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.