Open Editor
← All docs

Configuration reference

Everything you can pass to the editor, how to pass it from every framework, and what each option does. All options on this page are verified against the editor's actual DEFAULTS — if it's documented here, it works.

One package: everything ships as openeditor-text — the loader, the React/Vue/Angular wrappers, and the types. Earlier releases split the wrappers into separate packages; from 2.0 they are subpath imports of this one.

Contents


Installation

One package, whatever your framework:

npm i openeditor-text

The React, Vue and Angular wrappers ship inside that package as subpath imports — there is nothing extra to install:

Your stackImport from
Plain JavaScript / any frameworkopeneditor-text
React (and Next.js)openeditor-text/react
Vue 3openeditor-text/vue
Angular (≥ 17)openeditor-text/angular

React, Vue and Angular are optional peer dependencies, so you only pull in the one you already use.

Upgrading from 1.x? The separate openeditor-text-react, openeditor-text-vue and openeditor-text-angular packages are no longer used — uninstall them and switch to the subpath imports above. See what changed in 2.0 below.


Passing configuration

Plain JavaScript

Options are the second argument to createEditor:

import { createEditor } from 'openeditor-text';

const editor = await createEditor('#app', {
  endpoint: 'https://your-delivery-host.com',
  theme: 'dark',
  placeholder: 'Write something…',
  maxLength: 5000,
  onChange: ({ html }) => save(html),
});

The first argument is a selector string or an HTMLElement. The toolbar, status bar, and all styling are injected by the editor itself.

Two things are new in 2.x:

  • createEditor is asynchronous. Installing this package puts only a small loader on disk; the editor itself is downloaded when your page runs, so creating one returns a promise.
  • endpoint is required — it is where the editor is downloaded from. You get the URL when you sign up, or it is your own server if you self-host.

Everything else on this page is an ordinary editor option and behaves exactly as it always has.

React

The wrapper takes the same object via the config prop:

import { OpenEditor } from 'openeditor-text/react';

<OpenEditor
  value={html}
  onChange={(html) => setHtml(html)}
  config={{ placeholder: 'Write something…', maxLength: 5000 }}
/>

Reactive vs construct-time — the wrapper contract (all three frameworks):

KindPropsBehavior
Reactivevalue (v-model / ngModel), readOnly, theme, directionApplied live when they change
Construct-timeconfig, pluginsRead once at mount. To change them, remount (React: give the component a new key)
// Changing a construct-time option = remount with a key:
<OpenEditor key={locale} config={{ locale }} />

Controlled usage is caret-safe: the wrapper diffs out echoes of its own onChange, so feeding the same HTML back never moves the cursor.

Next.js

The module is safe to import on the server; construction needs a DOM, so render it client-side:

import dynamic from 'next/dynamic';

const OpenEditor = dynamic(
  () => import('openeditor-text/react').then((m) => m.OpenEditor),
  { ssr: false },
);

Then use it exactly as in React above — config file + component is the whole integration.

Vue 3

<script setup>
import { ref } from 'vue';
import { OpenEditor } from 'openeditor-text/vue';

const html = ref('<p>Hello</p>');
const config = { placeholder: 'Write something…', maxLength: 5000 };
</script>

<template>
  <OpenEditor v-model="html" :config="config" />
</template>

A useOpenEditor() composable is also available for bring-your-own-element setups — see the Vue wrapper's types.

Angular

Standalone component + ControlValueAccessor — works with [(ngModel)] and reactive forms:

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { OpenEditorComponent } from 'openeditor-text/angular';

@Component({
  standalone: true,
  imports: [FormsModule, OpenEditorComponent],
  template: `<open-editor [(ngModel)]="html" [config]="config"></open-editor>`,
})
export class AppComponent {
  html = '<p>Hello</p>';
  config = { placeholder: 'Write something…', maxLength: 5000 };
}

FormControl.disable() automatically switches the editor to read-only.


Validation & safety

  • Unknown top-level keys are ignored with a console warning, and the warning suggests the closest known key (a misspelling like readOnly → suggests readonly). Nothing throws.
  • __proto__ / constructor / prototype keys are dropped — the config merge is prototype-pollution-safe.
  • Only the top level is checked; nested shapes (autosave.*, onChange.*) are intentionally open.

Core options

OptionTypeDefaultDescription
debugbooleanfalseEnable info/warn console logging.
loggerobject | nullnullCustom logger { info, warn, error }.
toolbarboolean | objecttrueRender the toolbar; object form customizes it (see Toolbar).
statusBarbooleantrueRender the status bar (live word count, char count, cursor line/column).
readonlybooleanfalseStart read-only (content viewable + selectable, editing off).
spellcheckbooleanfalseNative browser spellcheck on the editable. Off by default — it causes layout reflows on every keystroke in Chrome.
autofocusbooleanfalseFocus the editor on mount.
iframebooleanfalseRender the editable inside a sandboxed iframe (full style isolation from the host page).
direction'ltr'|'rtl''ltr'Base text direction.
theme'light'|'dark'|'minimal'|'auto''light'Initial theme; 'auto' follows the OS. See THEMING.md.

Sizing

OptionTypeDefaultDescription
minHeightnumber (px)200Minimum editable height.
maxHeightnumber | nullnullMax height before the content area scrolls.
heightnumber | nullnullShorthand — sets both min and max unless they're given explicitly.
// A fixed 500px editor that scrolls internally (Jodit-style fixed height):
await createEditor('#app', { endpoint, height: 500 });

// Grows from 300px, scrolls after 600px:
await createEditor('#app', { endpoint, minHeight: 300, maxHeight: 600 });

Content & security

OptionTypeDefaultDescription
defaultContentstring (HTML)''Initial HTML, set at construction (no post-init setHTML needed).
placeholderstring'Start typing…'Empty-state placeholder. Pass '' to disable.
sanitizebooleantrueSanitize input and output HTML. Leave true unless you fully trust all content.
allowTagsstring[] | nullnullExtra tags to keep (adds to the built-in safe set) — e.g. custom elements for a CMS. Cannot re-enable denied tags (script/iframe/object/form/… — the deny-list always wins, with a console warning).
allowAttributesobject | nullnullExtra attributes per tag, e.g. { 'my-note': ['data-kind'] }. Cannot enable on* handlers or srcdoc (always stripped, with a warning), and URL-sink attributes (href/src/action/…) stay scheme-checked on ANY tag. These guarantees are CI-locked by an adversarial test sweep.
denyTagsstring[] | nullnullTags to strip even if otherwise allowed (narrows the built-in set).
maxLengthnumber | nullnullMax character count; blocks further input and emits maxLengthExceeded.
await createEditor('#app', {
  endpoint,
  defaultContent: '<p>Draft loaded from the server…</p>',
  maxLength: 10000,
  allowTags: ['my-note'],
  allowAttributes: { 'my-note': ['data-kind'] },
});

See SECURITY.md for the full sanitizer model.


Toolbar

toolbar accepts three forms:

toolbar: true                  // default — the full built-in toolbar
toolbar: false                 // no toolbar at all (bring your own UI)
toolbar: { items: [ /* groups */ ] }   // custom layout

Custom layout — toolbar.items

items is an array of groups; each group is an array of item descriptors. Groups render with a separator between them.

An item descriptor is one of:

ShapeRenders
{ type: 'button', name, command, icon, labelKey }A command button
{ type: 'dropdown', name, kind, labelKey }A dropdown — kind: heading | fontFamily | fontSize | lineHeight | changeCase | styles | textPartLanguage
{ type: 'color', name, kind, icon, labelKey }A color picker — kind: textColor | bgColor
{ type: 'listStyle', name, command, icon, labelKey, listTag }List button with style flyout (listTag: 'ul' | 'ol')
{ type: 'alignment', name: 'alignment', labelKey: 'alignment' }The alignment picker

A minimal writing toolbar:

await createEditor('#app', {
  endpoint,
  toolbar: {
    items: [
      [
        { type: 'button', name: 'bold',   command: 'bold',   icon: 'bold',   labelKey: 'bold' },
        { type: 'button', name: 'italic', command: 'italic', icon: 'italic', labelKey: 'italic' },
      ],
      [
        { type: 'dropdown', name: 'heading', kind: 'heading', labelKey: 'heading' },
      ],
      [
        { type: 'button', name: 'undo', command: 'undo', icon: 'undo', labelKey: 'undo' },
        { type: 'button', name: 'redo', command: 'redo', icon: 'redo', labelKey: 'redo' },
      ],
    ],
  },
});

The default layout (for copy-paste editing)

The built-in toolbar is these groups, in order — recreate it and remove what you don't want:

GroupItem names
textbold, italic, underline, strikethrough, superscript, subscript, inlineCode, removeFormat, changeCase (dropdown), styles, textPartLanguage
blockheading, fontFamily, fontSize, lineHeight (dropdowns), blockquote
colortextColor, bgColor
lists + alignul, ol (listStyle), outdent, indent, alignment
insertinsertHorizontalRule, insertPageBreak
historyundo, redo
viewfullscreen, print (action buttons), showBlocks

* styles and textPartLanguage only render when their config option (styles / textPartLanguages) is non-empty.

Plugins (image, link, table, emoji, …) contribute their own toolbar buttons when installed — you don't list those here.

Custom buttons

Custom buttons use the same descriptor as built-ins, with an inline SVG icon and optionally your own registered command:

editor.commands.register('shout', {
  execute: (ed) => ed.commands.execute('insertText', 'HEY!'),
});

// in config:
toolbar: {
  items: [
    [{ type: 'button', name: 'shout', command: 'shout',
       icon: '<svg viewBox="0 0 24 24">…</svg>', tooltip: 'Shout' }],
  ],
}

Paste

OptionTypeDefaultDescription
askBeforePasteHTMLbooleantruePrompt Keep / Text / Only on rich HTML paste.
askBeforePasteFromWordbooleantruePrompt on Word/Excel paste.
defaultActionOnPaste'keep'|'text'|'only''keep'Action when not prompting.
defaultActionOnPasteFromWordsame | nullnullWord-specific default (nulldefaultActionOnPaste).
pasteStripStylesbooleantrueDrop leftover inline styles after cleanup promotion.
// Never prompt; always clean pasted HTML down to canonical tags:
await createEditor('#app', {
  endpoint,
  askBeforePasteHTML: false,
  askBeforePasteFromWord: false,
  defaultActionOnPaste: 'keep',
});

Ctrl/Cmd+Shift+V always pastes as plain text.


Images & uploads

Connecting uploads to your own API + database? This table is the reference; the step-by-step setup guide (with server examples for Node/Express, Next.js, and NestJS, plus auth) lives in Image uploads.

OptionTypeDefaultDescription
imageUploadUrlstring | nullnullPOST endpoint for image uploads. See the contract below.
imageUploadHandler(file,{signal,onProgress})=>url | {url,width?,height?,sources?} | nullnullTake over the upload yourself — S3/R2 pre-signed URLs, Cloudinary, or any two-step flow imageUploadUrl cannot express. Wins over imageUploadUrl. See Image uploads.
imageUploadHeadersobject | (file) => object | nullnullExtra request headers — e.g. { Authorization: 'Bearer …' }, an API key, X-CSRF-Token. Never set Content-Type (it would break the multipart boundary; it is ignored).
imageUploadWithCredentialsbooleanfalseSend cookies on a cross-origin upload (same-site session auth).
imageUploadFieldNamestring | nullnullOverride the multipart field name (default file) — e.g. image, upload.
imageUploadDataobject | (file) => object | nullnullExtra form fields sent with the file (folder id, CSRF token, post id to link the upload in your DB).
imageUploadResponse(json) => url | {url,sources?} | nullnullMap a custom server response shape to a URL (Jodit's process() equivalent).
imageMaxFileSizenumber10485760Max upload size in bytes (default 10 MB) — enforced on dialog, drop, and paste.
imageRequireAltbooleanfalseRequire non-empty alt text before an image can be inserted (accessibility).
imageAllowDataUribooleanfalsePermit data: image URIs (security-relevant — off by default).
imageDefaultWidthnumber | nullnullWidth applied to inserted images that carry no size.
imageAvailableClasses[{value,label}] | nullnullClass dropdown in the Image Properties dialog.
imageOpenOnDblClickbooleantrueDouble-click an image opens its properties dialog.

The upload contract

When imageUploadUrl is set, every inserted file (file picker, drag-and-drop, clipboard paste) is sent as:

  • Request: POST imageUploadUrl, multipart/form-data, file in the file field.
  • Response: JSON with the hosted URL at the top level:
{ "url": "https://cdn.example.com/uploads/photo.webp" }

{ "src": "…" } is accepted as an alias. Optionally include sources to emit a responsive <picture> (the <img> stays as fallback):

{
  "url": "https://cdn.example.com/photo.jpg",
  "sources": [
    { "srcset": "https://cdn.example.com/photo.avif", "type": "image/avif" },
    { "srcset": "https://cdn.example.com/photo-800.jpg 800w", "sizes": "100vw" }
  ]
}

Every returned URL (including each srcset) is scheme-checked with the same URL policy as any src — an unsafe URL rejects the whole upload. The common nested shape { "data": { "url": … } } (NestJS/Laravel-style) is understood out of the box. For any other shape, map it with imageUploadResponse — a function that receives the parsed JSON and returns the URL string (or { url, sources }); this is the equivalent of Jodit's process() hook. Full examples are in the Image uploads guide.

Without imageUploadUrl: local files become data: URIs, which are blocked unless you opt in with imageAllowDataUri: true. Inserting by URL always works.

await createEditor('#app', {
  endpoint,
  imageUploadUrl: 'https://api.example.com/uploads/editor-image',
  imageDefaultWidth: 480,
  imageAvailableClasses: [
    { value: 'img-hero', label: 'Hero' },
    { value: 'img-thumb', label: 'Thumbnail' },
  ],
});

(The image plugin itself — resize handles, alignment, properties dialog, captions — is covered in PLUGINS.md.)


Tables

OptionTypeDefaultDescription
tableAvailableClasses[{value,label}] | nullnullStyle presets offered on table insert.
tableDefaultClassstring | nullnullClass applied to inserted tables when no preset is chosen.
tableDefaultHeaderRowbooleanfalseFirst row becomes a header on insert.

Typing behavior

OptionTypeDefaultDescription
autoformatbooleantrueMarkdown-style typing shortcuts (**bold**, # heading, - list, > quote, …). false disables entirely.
textTransformationsboolean | objecttrueTyping autocorrect: (c)→©, (r)→®, (tm)→™, 1/2→½-style fractions, --→– and ---→— (on the following space), smart quotes. false disables all, or per-group { symbols, fractions, dashes, smartQuotes }. Skipped inside <code>/<pre>; one undo restores the literal text.
formatPainterStickybooleanfalseFormat painter stays armed until toggled off.
mentions{ source } | nullnull@mentions data provider: { source: (query) => Promise<[{id,label}]> }. Used only if the mentions plugin is installed.

Insert dialogs & view

OptionTypeDefaultDescription
specialCharacters[{ch,label}] | string[] | nullnullSpecial-chars grid (null = built-in set).
emojis[{ch,label,cat,keywords}] | nullnullEmoji grid (null = built-in set).
codeBlockLanguages[{value,label}] | nullnullCode-block language selector (null = built-in).
sourceModeBeautifybooleantruePretty-print HTML in source view.
sourceModeHighlightbooleantrueSyntax-highlight the source view (scroll-synced overlay). false = plain textarea.
inlineToolbarbooleanfalseFloating selection toolbar (bold/italic/underline/quote above a selection).
blockquoteToolbarbooleantrueBlockquote style toolbar.
styles[{label, element?, classes}] | nullnullNamed style presets → a toolbar Styles dropdown (rendered only when non-empty). Block element (p/h1–h6/blockquote/pre) converts the block + applies classes; element absent/'span' wraps the selection in a classed span.
textPartLanguages[{code, label?}] | nullnullLanguage list → a toolbar Language dropdown. Wraps the selection in <span lang> (auto dir="rtl" for RTL scripts) — WCAG 3.1.2 Language of Parts.
warnOnUnloadbooleanfalseNative browser prompt before closing a tab with unsaved changes (isDirty). Opt-in.

Callbacks & persistence

onChange — one of:

  • a function ({ html, text }) => {} — called on every (debounced) change;
  • { handler, debounce } — same, with a custom debounce (ms);
  • { debounce } — event-only (listen via editor.on('onChange', …)).

The onChange event always fires regardless of this option. In the framework wrappers you use the onChange prop / v-model / ngModel instead — don't double-wire both.

autosavenull (off) or:

autosave: {
  storage: 'localStorage',   // only 'localStorage' is supported
  key: 'oe-draft',           // storage key (default 'oe-draft')
  interval: 30000,           // save interval in ms (default 30000)
  restore: true,             // restore a saved draft on load (default true)
}

Emits autosaveSaved / autosaveRestored / autosaveFailed / autosaveDraftSkipped.


Internationalization (locale)

Open Editor ships a built-in English label set plus four complete locale packs — Spanish, French, German, and Arabic — and a bring-your-own-locale mechanism. Whatever you pass is merged over the English defaults, so partial maps are fine (anything you omit falls back to English).

locale takes a translation map — an object whose keys are label names. Whatever you pass is merged over English, so partial maps are fine:

const editor = await createEditor('#app', {
  endpoint,
  locale: {
    bold: 'Gras',
    italic: 'Italique',
    insertLink: 'Insérer un lien',
    // …only the keys you translate; the rest stay English
  },
});

For a right-to-left language, pair the map with direction:

const editor = await createEditor('#app', {
  endpoint,
  locale: myArabicLabels,
  direction: 'rtl',
});

Or pass a language code to use one of the four shipped packs — Spanish, French, German and Arabic:

const editor = await createEditor('#app', { endpoint, locale: 'es' });

// A region suffix works too, matched by its base language:
await createEditor('#app', { endpoint, locale: navigator.language }); // 'es-MX' → Spanish

Every pack covers the full label set (CI-enforced, so a new UI string can never ship untranslated). An unknown code falls back to English rather than throwing.

Changed in 2.0. In 1.x you imported a pack — import { localeAr } from 'openeditor-text'. That is no longer possible: the engine is downloaded at runtime, so there is nothing on disk to import, and openeditor-text/locales/* does not resolve. Pass the code (or a map) instead — the packs travel with the engine, so no import is needed.

The status bar uses CJK-aware word counting.


Events

Configuration callbacks cover the common cases; everything else is on the frozen event map via editor.on(name, handler) (also once / off):

editor.on('ready', () => console.log('editor is live'));
editor.on('maxLengthExceeded', ({ maxLength }) => toast(`Limit: ${maxLength}`));
GroupEvents
ContentbeforeChange, onChange, beforeSetHTML, setHTML, reset, maxLengthExceeded
Focus & selectionfocus, blur, selectionChange
StatestateChange, readOnlyChange, directionChange, themeChange
CommandsbeforeCommand*, afterCommand, undo, redo
PastebeforePaste*, afterPaste
LifecyclebeforeInit, init, afterInit, ready, beforeDestroy, destroy
AutosaveautosaveSaved, autosaveRestored, autosaveFailed, autosaveDraftSkipped
Plugins & errorspluginInstalled, pluginUninstalled, error

* Cancelable — the payload has preventDefault().

In the wrappers, the common ones are props/outputs: React onChange/onReady/onFocus/onBlur/onError; Vue emits change/ready/focus/blur/error; Angular outputs changed/ready/focused/blurred/errored. For anything else, grab the instance (ref / template ref / @ViewChild) and call editor.on(…).


What changed in 2.0

The editor is no longer installed — it is delivered. npm install puts a small loader in node_modules, and the engine is downloaded, integrity-checked and mounted when your page loads. The payoff is that entitlements become live: buy a licence and the features appear on the next page load, with no reinstall, no redeploy and no version bump.

Four things changed for you:

1.x2.x
new OpenEditor('#app', {…})await createEditor('#app', {…})
endpoint is required
npm i openeditor-text openeditor-text-reactnpm i openeditor-text, import openeditor-text/react
import { localeAr } from 'openeditor-text'locale: 'ar' — no import needed

Construction is asynchronous. The engine arrives over the network, so createEditor returns a promise:

// 1.x
const editor = new OpenEditor('#app', { theme: 'dark' });

// 2.x
const editor = await createEditor('#app', {
  endpoint: 'https://your-delivery-host.com',
  theme: 'dark',
});

The wrapper packages are retired. openeditor-text-react, openeditor-text-vue and openeditor-text-angular stopped at 1.2.0 and do not work against a 2.x core — they call the old constructor, which no longer exists as a value. Uninstall them and switch to the subpath imports:

npm uninstall openeditor-text-react   # or -vue / -angular
import { OpenEditor } from 'openeditor-text/react';

Locale packs are selected by code, not imported. locale: 'ar' replaces import { localeAr } — the packs travel with the engine, so there is nothing to import. See Internationalization above.

Everything else — every option, event, command and plugin on this page — is unchanged. Once the editor is constructed, 2.x behaves exactly like 1.x.


Migrating from Jodit

The most-used Jodit options and their equivalents here:

JoditOpen EditorNote
height / minHeight / maxHeightsame namesheight sets min+max together
placeholderplaceholder'' disables
readonlyreadonly
toolbar: falsetoolbar: false
buttons: [...]toolbar: { items: [...] }Groups of descriptors instead of a flat name list — see Toolbar
toolbarAdaptiveToolbar overflow-scrolls on mobile automatically
enter: 'div'Not configurable: blocks are always canonical <p>
spellcheckspellcheck
iframeiframe
directiondirection
languagelocalePacks: es/fr/de/ar ship in the box
themethemelight / dark / minimal / auto
askBeforePasteHTML / askBeforePasteFromWordsame names
defaultActionOnPaste: 'insert_clear_html'defaultActionOnPaste: 'keep'Values: keep / text / only
showCharsCounter / showWordsCounterstatusBarBoth counters are built into the status bar
uploader.urlimageUploadUrlMultipart file field; response { "url": … } — no process() hook, flat response required
uploader.insertImageAsBase64URIimageAllowDataUriOff by default (security)
disablePluginsInverse model: plugins are opt-in installs, nothing to disable
events: { … }onChange config + editor.on(…)See Events
style: { … }themes / CSS custom propertiesSee THEMING.md

Two habits you can drop: wiring both onBlur and onChange to capture content (here onChange alone is reliable and caret-safe), and saveSelectionOnBlur-style flags (selection save/restore across toolbar clicks is built in).


More: THEMING.md · THEME-TOKENS.md · PLUGINS.md · ACCESSIBILITY.md · SECURITY.md · ERROR-REPORTING.md