@eira-core/react-eira-editor
v0.1.1
Published
A React rich text editor with an HTML value and form-friendly API, powered by Lexical.
Maintainers
Readme
@eira-core/react-eira-editor
A React + TypeScript editor powered by Lexical, with HTML values and opt-in feature plugins.
Installation
Choose your package manager. Your application needs React and React DOM ^18.2.0 || ^19.0.0.
npm
npm install @eira-core/react-eira-editorYarn
yarn add @eira-core/react-eira-editorpnpm
pnpm add @eira-core/react-eira-editorImport @eira-core/react-eira-editor/style.css once in your application, as shown below.
Quick start
import { useState } from 'react';
import { Editor } from '@eira-core/react-eira-editor';
import { formattingPlugin } from '@eira-core/react-eira-editor/plugins/formatting';
import { headingPlugin } from '@eira-core/react-eira-editor/plugins/headings';
import { historyPlugin } from '@eira-core/react-eira-editor/plugins/history';
import '@eira-core/react-eira-editor/style.css';
// Define outside render, or useMemo. The order controls toolbar order.
const plugins = [formattingPlugin, headingPlugin, historyPlugin];
export function ArticleEditor() {
const [html, setHTML] = useState('<p>Hello <strong>Eira</strong>!</p>');
return (
<Editor
plugins={plugins}
value={html}
onChange={setHTML}
locale="en"
placeholder="Write your article..."
aria-label="Article content"
minHeight={240}
maxHeight={600}
/>
);
}<Editor /> without plugins has a basic editable HTML surface and no toolbar. Each plugin registers its own nodes, editing behavior and toolbar controls. Plugins have separate npm entry points; the core does not import the feature modules. Unused feature code can be omitted by a consumer's bundler. Dependencies are still installed with the package.
Use value and onChange for controlled HTML, or defaultValue for uncontrolled usage. Import only the plugins you need, and keep the same plugins when displaying saved content in preview mode.
Editor props
All props are optional. The package exports EditorProps, EditorRef, EditorPlugin, EditorStyles, EditorClassNames, EditorFocusPosition, EditorLocale, EditorMessages, MentionItem and MentionOptions types.
Content and features
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value | string | — | Controlled HTML. Update it with the HTML received from onChange. |
| defaultValue | string | '' | Initial HTML for uncontrolled usage; read only on mount. |
| plugins | readonly EditorPlugin[] | [] | Registers content nodes, tools and editing behavior; keep stable while mounted. |
| placeholder | string | Based on locale | Empty content hint; Vietnamese or English by default. |
| toolbar | boolean | true | Shows the main toolbar. False retains plugin behavior and contextual tools. |
| disabled | boolean | false | Prevents editing, disables tools and removes content from the Tab order. |
| readOnly | boolean | false | Prevents editing; View HTML and Preview tools remain available. |
| preview | boolean | false | Content only, without editing UI, placeholder or default frame/padding. |
| mention | boolean | false | Enables @ suggestions; requires mentionPlugin. |
| mentionOptions | MentionOptions | {} | Local/API user search, custom rendering and callbacks. |
| autoFocus | boolean \| 'start' \| 'end' | false | Focuses once when first editable; true is equivalent to end. |
| locale | 'vi' \| 'en' | 'vi' | Language of built-in controls, tooltips, dialogs and messages. |
| messages | Record<string, string> | — | UI text overrides keyed by Vietnamese source labels. |
Appearance and accessibility
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | string | '' | Additional class on the outer wrapper. |
| classNames | EditorClassNames | — | Additional wrapper, toolbar and content classes. |
| styles | EditorStyles | — | Inline styles for wrapper, toolbar and content. |
| minHeight | CSSProperties['minHeight'] | CSS: 180px | Content minimum height; preview defaults to zero unless overridden. |
| maxHeight | CSSProperties['maxHeight'] | No limit | Content maximum height; overflow scrolls inside the content area. |
| id | string | — | ID on the editable content element. |
| name | string | — | Metadata only; does not create an input or a native FormData entry. |
| aria-label | string | Localized content label* | Accessible name. |
| aria-labelledby | string | — | IDs of elements that label the content. |
| aria-describedby | string | — | IDs of help or validation messages. |
| aria-invalid | AriaAttributes['aria-invalid'] | — | Marks invalid content. |
| aria-required | AriaAttributes['aria-required'] | — | Announces required content. |
*The default accessible label is omitted when id or aria-labelledby is supplied. Provide aria-label or aria-labelledby explicitly in forms; a contenteditable div is not a native labelable input.
Events and lifecycle
| Prop | Type | Description |
| --- | --- | --- |
| onChange | (html: string) => void | Receives changed HTML without debounce. |
| onFocus | () => void | Focus enters the editor from outside its content and tools. |
| onBlur | () => void | Focus leaves the editor and its tools/dialogs. |
| onReady | (api: EditorRef) => void | Once after initialization, including StrictMode; exposes the same API as ref. |
| onPaste | ClipboardEventHandler<HTMLDivElement> | Before default paste; preventDefault() cancels it. Not invoked while disabled/readOnly/preview. |
| onError | (error: Error) => void | Receives Lexical errors; errors are thrown when no handler is supplied. |
Available plugins
Keep the plugin configuration fixed for an editor's lifetime because it defines the document schema. To switch to a different configuration, remount with a new React key and pass a compatible HTML value. toolbar={false} hides the main toolbar while retaining the registered behavior, schema and contextual image/table controls.
| Import path after @eira-core/react-eira-editor/ | Export | Tools |
| --- | --- | --- |
| plugins/formatting | formattingPlugin | Bold, italic, underline, strikethrough |
| plugins/headings | headingPlugin | Paragraph and H1–H6 select; heading/quote nodes |
| plugins/font-size | fontSizePlugin | Font sizes 8–96 px, current/mixed selection indicator and reset to inherited size |
| plugins/font-family | fontFamilyPlugin, createFontFamilyPlugin(options?) | Font family, mixed selection and inherited reset; configurable font choices |
| plugins/line-height | lineHeightPlugin | Paragraph/heading/list-item line spacing, mixed selection indicator and inherited reset |
| plugins/indent | indentPlugin | Increase/decrease block indentation and nested list levels |
| plugins/text-align | textAlignPlugin | Left, center, right and justified text alignment with selection state |
| plugins/scripts | scriptPlugin | Subscript and superscript with active state |
| plugins/colors | colorPlugin | Text/background colors, palette, HEX picker, remove color |
| plugins/lists | listPlugin | Bullet/numbered lists |
| plugins/link | linkPlugin | Insert/edit/remove link, URL validation |
| plugins/image | createImagePlugin(options?) | File picker, asynchronous upload, insertion at saved caret, contextual width and alignment controls |
| plugins/file | createFilePlugin(options?) | Upload attachments as filename/size items with download and remove actions; no file preview |
| plugins/table | tablePlugin | Insert 1–20 rows/columns, first-row headers, contextual row/column tools, drag to resize column widths, remove table |
| plugins/history | historyPlugin | Undo/redo with availability state |
| plugins/html | htmlPlugin | Read-only view of the current HTML, including in readOnly mode |
| plugins/clear-format | clearFormatPlugin | Clear selected text formatting and reset block presentation |
| plugins/preview | previewPlugin | Read-only content preview in a sandboxed iframe |
| plugins/inline-toolbar | inlineToolbarPlugin, createInlineToolbarPlugin(options?) | Floating tools for selected text, using the installed feature plugins |
| plugins/mention | mentionPlugin | @ user suggestions from local items or an async API, with persistent IDs in HTML |
Toolbar action buttons use SVG icons, hover/focus tooltips and accessible names. Dialogs support Escape and keyboard input; tool dialogs never create nested HTML forms.
Import textAlignPlugin from @eira-core/react-eira-editor/plugins/text-align and add it to plugins for left, center, right and justified alignment. It applies to entire selected paragraphs, headings and list items, including text inside table cells. The active button follows the selection; mixed alignments leave all four buttons inactive. Alignment is stored as HTML text-align, survives reloads and supports undo/redo. The demo enables it in all form examples.
Import fontSizePlugin from @eira-core/react-eira-editor/plugins/font-size to enable the font-size select. It updates selected text or the size used for subsequent typing at a caret. Choose the default option to remove the explicit size and inherit the paragraph/heading size. HTML imports preserve explicit px/pt sizes (1–400), including nested styled text. The select displays imported sizes and mixed selections. The playground enables this plugin in all form examples.
Import lineHeightPlugin, indentPlugin and scriptPlugin from the paths above and add them to plugins as needed. The demo enables all three. Line spacing applies to entire selected text blocks, with presets 1, 1.15, 1.5, 1.75, 2, 2.5 and 3; the default option removes the override. Paragraphs and headings indent by 40 px per step, while list items use Lexical's structural nesting behavior when listPlugin is enabled. Outdent is disabled at level zero. Subscript/superscript apply to selected text or subsequent typing, are mutually exclusive and export as standard sub/sup tags. Block spacing, indentation and script formatting survive HTML reloads and support undo/redo. The tools respect disabled/readOnly mode.
Selecting a table cell reveals its contextual toolbar with insert above/below, insert left/right, delete row/column and delete table. The active row and column are shown. Drag a column's right border to change its width; the guide moves during the drag and the document updates on release as one undo step. Column widths are saved in HTML colgroup elements and survive reloads. Resize handles also support ArrowLeft/ArrowRight (10 px), Shift+Arrow (50 px) and Home (70 px minimum). Escape cancels a drag. Contextual controls are hidden outside the selected editor and for disabled/readOnly editors.
Content-only preview mode
<Editor value={html} plugins={plugins} preview />
// Or switch modes without remounting:
<Editor value={html} onChange={setHTML} plugins={plugins} preview={isPreview} />preview is a boolean prop (default false). When true, the editor displays its content without the main/inline toolbars, contextual image/table tools, resize handles, attachment remove buttons, editor border/padding, empty minimum height or placeholder. Content cannot be edited; links and attachment downloads remain available. Reuse the content's plugin configuration so its images, tables and attachments have the required node schema. External value updates still render in preview mode, and switching modes does not emit onChange or discard editing history. When preview ends, readOnly and disabled still apply.
This prop displays content directly in place; previewPlugin provides the separate toolbar action that opens a preview dialog. The standalone demo has a “Chỉ xem nội dung” checkbox for toggling the prop.
Inline selection toolbar
import { inlineToolbarPlugin } from '@eira-core/react-eira-editor/plugins/inline-toolbar';
import { formattingPlugin } from '@eira-core/react-eira-editor/plugins/formatting';
import { fontSizePlugin } from '@eira-core/react-eira-editor/plugins/font-size';
import { textAlignPlugin } from '@eira-core/react-eira-editor/plugins/text-align';
import { linkPlugin } from '@eira-core/react-eira-editor/plugins/link';
import { colorPlugin } from '@eira-core/react-eira-editor/plugins/colors';
const plugins = [fontSizePlugin, textAlignPlugin, formattingPlugin, linkPlugin, colorPlugin, inlineToolbarPlugin];
<Editor plugins={plugins} value={html} onChange={setHTML} />
// Use toolbar={false} to show only the floating selection tools.Selecting nonempty text with the mouse or keyboard reveals a floating toolbar with font size, left/center/right/justified alignment, bold/italic/underline/strikethrough, links and text/background colors. Only installed plugins appear; the inline toolbar reuses their controls and does not register duplicate nodes or runtimes. All tools use the editor's current locale/messages. Alignment applies to the selected paragraphs, while text formatting affects the selected text.
To configure tool order or include your own toolbar plugin, use createInlineToolbarPlugin({ tools: ['formatting', 'font-size', 'link', 'colors'] }) instead of inlineToolbarPlugin. Names match the descriptors in the editor's plugins array. Define the plugin configuration outside render or memoize it.
The floating toolbar follows the selection on scroll/resize, stays inside the viewport, wraps on narrow screens and keeps selection bookmarks alive while using selects or dialogs. It stays inside the editor's focus boundary for form integration. It hides when the selection collapses, moves outside the editor, scrolls out of view or the editor is disabled/readOnly. Use Alt+F10 to focus the floating tools after selecting text; Escape dismisses them and returns focus to the editor. The demo enables the inline toolbar in all form examples alongside the main toolbar.
Font family, clear formatting and preview
fontFamilyPlugin provides Arial, Verdana, Tahoma, Trebuchet MS, Times New Roman, Georgia, Courier New and monospace. createFontFamilyPlugin({ fonts: [{ label: 'Inter', value: 'Inter, sans-serif' }] }) supplies your own list. Load web fonts in the host app; the plugin does not fetch them. Font family applies to selected text or subsequent typing at a caret, supports mixed/imported font stacks, and persists in HTML. Choose the default option to inherit the host font.
clearFormatPlugin removes inline marks and styles from the selected text (or resets subsequent typing at a caret). For affected blocks it also removes alignment, indentation and line-height overrides and converts headings/quotes to paragraphs. Unselected text retains its inline formatting. Links, list hierarchy, tables, images and attachments remain intact. Changes support undo/redo.
previewPlugin opens a read-only view of the current HTML in a sandboxed iframe, including in readOnly mode. The preview has its own basic typography/table styles; styles and web fonts from your host app do not carry into the iframe. It does not change the editor value.
Languages and custom text
<Editor
plugins={plugins}
locale="en" // 'vi' (default) or 'en'
messages={{
'Chèn ảnh': 'Upload image',
'Đính kèm tệp': 'Upload document',
'Nhập nội dung...': 'Write your article...',
'Tệp tối đa {size} MB.': 'Files must be at most {size} MB.',
}}
/>messages overrides built-in text using the Vietnamese source labels as stable keys. Missing keys fall back to the selected locale. Exported editorMessages.vi and editorMessages.en list the built-in keys; useEditorTranslation() exposes the same translator to custom plugins. {size} and {column} placeholders are interpolated. Use locale="en" plus a complete messages map for another language. Locale/messages can change while editing without remounting or resetting the content. Explicit placeholder and ARIA props take precedence. The demo's language selector updates all editor examples; surrounding demo/form labels belong to the host app.
Upload files to your API
import { createFilePlugin } from '@eira-core/react-eira-editor/plugins/file';
const filePlugin = createFilePlugin({
maxFileSize: 20 * 1024 * 1024,
accept: '.pdf,.doc,.docx,.xlsx',
uploadFile: async (file, { signal }) => {
const data = new FormData();
data.append('file', file);
const response = await fetch('/api/files', { method: 'POST', body: data, signal });
if (!response.ok) throw new Error('Upload failed');
const { url } = await response.json();
return url;
},
});Add filePlugin to the editor's stable plugins array. Uploads insert at the saved caret only after success. Canceling or unmounting aborts the request; errors leave the document unchanged. accept is a native picker hint; validate types on your API. The item shows filename, size, a download link and a remove button when editable. Cross-origin downloads depend on your server's response headers/browser behavior. File URLs and metadata are stored in standard anchors with data-eira-file, data-file-name, data-file-size and download attributes; preserve these attributes in your storage sanitizer. File-only content is a nonempty form value.
createFilePlugin() without an API embeds an application/octet-stream data URL locally (10 MiB limit), as used in the demo. There is no remote upload until you supply uploadFile. Files are always shown as items, without a document/image preview. Production URLs may be HTTP(S) or relative paths; temporary blob URLs and active data URL types are rejected.
Upload images to your API
import { createImagePlugin } from '@eira-core/react-eira-editor/plugins/image';
const imagePlugin = createImagePlugin({
maxFileSize: 10 * 1024 * 1024,
uploadImage: async (file, { signal }) => {
const body = new FormData();
body.append('file', file);
const response = await fetch('/api/uploads', { method: 'POST', body, signal });
if (!response.ok) throw new Error('Upload failed');
const { url } = await response.json();
return url;
},
});
const plugins = [imagePlugin];Supply an endpoint and authentication appropriate to your app. The default createImagePlugin() embeds a raster image as a data URL without sending it to a server, which is what the playground uses. Supported file types: PNG, JPEG, WebP, GIF, AVIF. The default size limit is 10 MiB. Upload errors stay in the dialog; canceling aborts the callback's signal. Image insertion restores the caret/selection saved before the picker opened. Click an image to select it; Backspace/Delete removes it.
Selecting an image reveals width controls (1–4000 px or 1–100%), an automatic-size button and inline/left/center/right alignment buttons. Apply the width with the check button or Enter. Height remains automatic to preserve the aspect ratio, and display width is capped at the editor's available width. Left/center/right alignment places the image on its own line; inline restores placement within surrounding text. Width and alignment are stored as standard HTML image styles, survive HTML reloads and support undo/redo. These controls are hidden for disabled/readOnly editors. Pointer drag resizing and drag/drop upload are not implemented yet.
Styling, lifecycle and imperative API
<Editor
plugins={plugins}
className="article-editor"
classNames={{ wrapper: 'article-frame', toolbar: 'article-toolbar', content: 'article-content' }}
styles={{
wrapper: { borderColor: '#ddd' },
toolbar: { backgroundColor: '#fafafa' },
content: { padding: 20, fontSize: 16 },
}}
minHeight={240}
maxHeight="60vh"
/>styles and classNames accept wrapper, toolbar (main toolbar only) and content entries. Built-in classes remain; className also applies to the wrapper. minHeight and maxHeight size the content area, accepting CSS numbers/strings. Explicit styles.content values take precedence. Default content min-height is 180 px, or zero in preview.
autoFocus accepts false (default), true/'end' or 'start'. It runs once per mount when the editor first becomes editable. onReady(api) fires once after initialization, with the same API exposed through ref. onPaste receives a React clipboard event before default paste; event.preventDefault() cancels the default insertion, allowing a custom handler to call ref.current.insertHTML(html).
The ref exposes focus(position?), blur(), getHTML(), getText(), isEmpty(), setHTML(html), insertHTML(html), clear(), canUndo(), canRedo(), undo() and redo(). Mutations emit onChange for changed HTML and support undo when historyPlugin is enabled. They return false while disabled/readOnly/preview. Getters work in all modes. HTML insertion uses the current or last valid text selection, falling back to the document end. External value replacement still resets history; ref mutations are undoable edits.
import { useRef } from 'react';
import { Editor, type EditorRef } from '@eira-core/react-eira-editor';
export function EditorWithActions() {
const ref = useRef<EditorRef>(null);
return <>
<Editor ref={ref} defaultValue="<p>Hello!</p>" />
<button type="button" onClick={() => console.log(ref.current?.getHTML())}>Read HTML</button>
<button type="button" onClick={() => ref.current?.focus('end')}>Focus editor</button>
</>;
}| Ref method | Returns | Description |
| --- | --- | --- |
| focus(position?) | void | Focus at start/end; without a position uses Lexical's default selection behavior. |
| blur() | void | Blur the content. |
| getHTML() | string | Current HTML. |
| getText() | string | Plain text content. |
| isEmpty() | boolean | Whether the editor considers content empty. |
| setHTML(html) | boolean | Replace content with sanitized HTML. |
| insertHTML(html) | boolean | Insert sanitized HTML at the current/saved selection or end. |
| clear() | boolean | Clear content. |
| canUndo() / canRedo() | boolean | Available history operations while editable; require historyPlugin. |
| undo() / redo() | boolean | Perform an available history operation. |
User mentions
import { mentionPlugin } from '@eira-core/react-eira-editor/plugins/mention';
import type { MentionOptions } from '@eira-core/react-eira-editor';
const plugins = [mentionPlugin];
const mentionOptions: MentionOptions = {
items: [{ id: 'u1', label: 'Nguyễn An', description: 'Developer' }],
};
<Editor plugins={plugins} mention mentionOptions={mentionOptions} />mention defaults to false and requires mentionPlugin in the stable plugin array. Turning suggestions off preserves existing mentions. Type @ at a text boundary to open suggestions; use arrows, Enter/Tab, Escape or mouse selection. Local matching ignores case and Vietnamese accents. Emails do not trigger suggestions. A selection inserts an atomic mention followed by a space; Backspace deletes the whole token. Add historyPlugin for undo/redo.
For remote search, supply loadItems: (query, { signal }) => Promise<readonly MentionItem[]> instead of items. Requests are debounced, aborted on query changes/close/unmount, and stale responses are ignored. The package does not provide a user endpoint. MentionItem contains required string id/label, optional avatar/description. Menu messages follow locale/message overrides. Suggestions are hidden in disabled/readOnly/preview modes; onClick remains available in readOnly/preview.
| mentionOptions field | Type | Default / description |
| --- | --- | --- |
| items | readonly MentionItem[] | []; local matching ignores case and Vietnamese accents. |
| loadItems | (query, { signal }) => Promise<readonly MentionItem[]> | Optional remote search; takes precedence over items. |
| minChars | number | 0; query length after @, clamped to 0–50. |
| debounceMs | number | 250; API search debounce. |
| limit | number | 8; maximum results, clamped to 1–100. |
| renderItem | (item, { selected }) => ReactNode | Custom suggestion row content. |
| onSelect | (item: MentionItem) => void | Called when a user is selected and inserted. |
| onClick | (item: Pick<MentionItem, 'id' \| 'label'>) => void | Clicking a mention; blocked when disabled. |
const remoteMentionOptions: MentionOptions = {
loadItems: async (query, { signal }) => {
const response = await fetch(`/api/users?q=${encodeURIComponent(query)}`, { signal });
if (!response.ok) throw new Error('Unable to load users');
return response.json(); // [{ id: 'u1', label: 'Nguyễn An' }]
},
};Mention IDs and display text survive HTML round-trips and rich HTML paste using <span data-eira-mention="true" data-mention-id="u1">@Nguyễn An</span> (formatting may add inner tags). Keep these attributes in your storage sanitizer. Avatars/descriptions are not persisted, names are not automatically refreshed, and plain text mentions have no user identity. Without the plugin, imported mentions become readable text. The editor emits HTML through the same form API; your backend resolves and validates IDs before sending notifications.
Forms
- Ant Design: place the editor directly in
Form.Item name="content". Supplyaria-labeloraria-labelledbyexplicitly because a contenteditable div is not a native labelable input. - React Hook Form: use
Controllerand<Editor {...field} plugins={plugins} />. - Formik: use
useField,onChange={html => helpers.setValue(html)}andonBlur={() => helpers.setTouched(true)}.
Use value/onChange for controlled forms, or defaultValue for uncontrolled use. The forwarded ref exposes focus() and blur(). id and ARIA attributes reach the editable element. name is metadata; native FormData submission is not implemented. disabled and readOnly prevent editing. onBlur fires when focus leaves the editor and its tools/dialogs.
HTML changes are emitted without debounce. Echoing the emitted value does not reimport the document or reset the caret. External replacements reset history. Mounting does not emit a normalized replacement of the initial value. Empty text exports as ''; image-only and table-only documents remain nonempty for form validators.
HTML and schema
HTML is converted to the registered schema and is not preserved byte-for-byte. Imports allow supported HTML tags and sanitize attributes; text style import retains color/background-color. These styles, links, images and tables survive export/import when the corresponding plugins are enabled. Unknown nodes are not retained by editors that lack their plugin. Nested tables and merged-cell editing are not supported. Use server-side validation and sanitization at your app's storage/render boundary as well.
Custom plugins
The package exports EditorPlugin and PluginToolbarProps types. A descriptor has a unique name, optional Lexical nodes, an optional Runtime component and an optional Toolbar component. Components render inside the Lexical composer. Runtime features still mount with toolbar={false}. Look at the bundled plugin source for examples.
Compatibility and license
The editor uses browser DOM APIs; render it in a client component when using a framework with server components. Current automated browser checks cover Chromium with React 19. Validate native IME input and other target browsers/React versions in your application.
MIT license. Visit the homepage for the library's documentation.
