@mdaemon/html-editor
v1.13.1
Published
A TinyMCE-compatible HTML editor built on TipTap
Downloads
1,089
Maintainers
Readme
@mdaemon/html-editor
A TinyMCE-compatible HTML editor built on TipTap
MDHTMLEditor is the core vanilla TypeScript package - for React usage, see @mdaemon/html-editor-react.
Install
npm install @mdaemon/html-editorTipTap and ProseMirror are bundled into the published output (currently TipTap 3.31.x) —
there are no peer dependencies to install and no TipTap version for the host app to match. If
your app uses TipTap directly as well, note that the copy inside this package is separate from
yours; pass content across the boundary as HTML (getContent() / setContent()) rather than
sharing ProseMirror nodes or extension instances between the two.
Usage
Basic usage
import { HTMLEditor } from '@mdaemon/html-editor';
import '@mdaemon/html-editor/styles';
// Create editor with built-in language support
const container = document.getElementById('editor');
const editor = new HTMLEditor(container, {
height: 400,
language: 'de', // German UI — see Localization section for supported codes
basicEditor: false, // Full toolbar
includeTemplates: true,
templates: [
{ title: 'Greeting', content: '<p>Hello!</p>' }
],
fontName: 'Arial',
fontSize: '12pt',
});
// Get/set content
const html = editor.getContent();
editor.setContent('<p>New content</p>');
// Insert content at cursor
editor.insertContent('<b>Bold text</b>');
// Execute commands (TinyMCE compatible)
editor.execCommand('bold');
editor.execCommand('fontsize', false, '14pt');
// Listen to events
editor.on('change', (content) => {
console.log('Content changed:', content);
});
editor.on('dirty', (isDirty) => {
console.log('Editor dirty state:', isDirty);
});
// Clean up
editor.destroy();Browser (UMD)
For static pages without a bundler, load the self-contained UMD bundle via a <script> tag.
It exposes the entire public API under the MDHTMLEditor global. Styles must be loaded
separately — the UMD bundle contains JavaScript only.
<link rel="stylesheet" href="https://unpkg.com/@mdaemon/html-editor/dist/styles.css">
<script src="https://unpkg.com/@mdaemon/html-editor"></script>
<script>
const editor = new MDHTMLEditor.HTMLEditor(
document.getElementById('editor'),
{ height: 400 }
);
</script>unpkg and jsdelivr both serve the package; pin a version for production
(e.g. https://unpkg.com/@mdaemon/[email protected]).
Custom Toolbar Buttons
const editor = new HTMLEditor(container, {
setup: (ed) => {
ed.ui.registry.addButton('myButton', {
tooltip: 'My Custom Button',
text: 'Click Me',
onAction: () => {
ed.insertContent('<p>Custom content!</p>');
},
});
},
});Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| basicEditor | boolean | false | Use simplified toolbar for notes/tasks |
| readonly | boolean | false | Start the editor in read-only mode (see setReadOnly) |
| forced_root_block | 'p' | 'div' | 'p' | Block element produced on Enter. 'div' gives CKEditor ENTER_DIV parity |
| trailingNode | boolean | false | Append an empty trailing paragraph when the document ends in a block node (table, image, code block) so the cursor can be placed after it |
| includeTemplates | boolean | false | Show template dropdown |
| templates | Template[] | [] | Array of templates |
| dropbox | boolean | false | Enable Dropbox integration |
| images_upload_url | string | - | URL for image uploads |
| images_upload_credentials | boolean | true | Include credentials in CORS upload requests |
| images_upload_base_path | string | '/' | Base path prefix for uploaded image URLs |
| images_upload_max_size | number | 10485760 | Maximum upload file size in bytes (default 10 MB) |
| images_upload_headers | Record<string, string> | - | Custom HTTP headers for upload requests |
| images_file_types | string | (permissive) | Comma/space-separated accepted extensions (e.g. 'jpg,jpeg,png,gif,bmp'). Restricts uploads when set |
| images_upload_validate | (file: File) => string | null | - | Pre-upload hook; return a message to reject the file, or null to allow |
| images_upload_error | (message: string) => void | - | Caller-supplied alert for drag-drop/paste upload rejections & failures (no dialog to show errors in) |
| font_family_formats | string | (TinyMCE defaults) | Semicolon-separated font list (Name=family,...) |
| font_size_formats | string | '8pt 9pt 10pt 12pt 14pt 18pt 24pt 36pt' | Space-separated font sizes |
| font_names | string | - | CKEditor alias for font_family_formats |
| fontSize_sizes | string | - | CKEditor alias for font_size_formats |
| block_formats | string | 'Paragraph=p;Heading 1=h1;…' | Block-format dropdown definitions (blocks button) |
| style_formats | StyleFormat[] | (subset) | Named styles for the Styles dropdown (styles button) |
| color_map | string[] | ColorOption[] | - | Palette for both color pickers (see Color Pickers) |
| color_map_foreground | string[] | ColorOption[] | (40 text colors) | Palette for the forecolor picker; overrides color_map |
| color_map_background | string[] | ColorOption[] | (26 highlight colors) | Palette for the backcolor picker; overrides color_map |
| fontName | string | arial, helvetica, sans-serif | Default font family, inlined on every block element (see Font Family & Size) |
| fontSize | string | 12pt | Default font size, inlined on every paragraph (see Font Family & Size) |
| directionality | 'ltr' | 'rtl' | 'ltr' | Text direction |
| language | string | 'en' | UI language code (built-in translations for 31 languages) |
| height | string | number | 300 | Editor height (fixed) |
| min_height | string | number | - | Minimum editor height |
| max_height | string | number | - | Maximum editor height |
| auto_focus | string | - | Auto-focus the editor on init |
| skin | 'oxide' | 'oxide-dark' | 'confab' | 'confab-dark' | 'oxide' | Theme (see Theming section) |
| content_css | 'default' | 'dark' | 'confab' | 'confab-dark' | 'default' | Content area styling |
| content_style | string | - | Custom CSS injected into the content area |
| toolbar | string | (see Toolbar section) | Custom toolbar button layout |
| toolbar_mode | 'sliding' | 'floating' | 'wrap' | 'wrap' | Toolbar overflow behavior |
| toolbar_sticky | boolean | true | Keep toolbar pinned at the top while scrolling |
| browser_spellcheck | boolean | true | Enable browser spell-check |
| entity_encoding | 'raw' | 'named' | 'numeric' | 'raw' | HTML entity encoding mode |
| convert_unsafe_embeds | boolean | true | Sanitize embedded content |
| format_empty_lines | boolean | true | Preserve blank lines in exported content. When true, every path that hands HTML out of the editor (getContent(), the change event payload, the source dialog, preview) injects a <br> into each empty block so blank lines keep their height in mail clients and other consumers that would otherwise collapse a bare <div></div>/<p></p> to zero height, and every path that brings HTML in (setContent(), insertContent(), the Templates dropdown, saving the source dialog) runs the exact inverse — stripping that <br> back out on import so a blank line is not re-parsed as a doubled line. This keeps setContent(getContent(x)) stable across round-trips (skin/root-block changes, saved-draft reloads). A block holding only a non-breaking space (<div> </div>) is not a blank line — an is visible content that already gives the block height — so it is left exactly as it is by both passes. Set false to pass the engine's output through unchanged in both directions. |
| paste_from_office | boolean | true | Clean and preserve formatting when pasting from Microsoft Word and Excel |
| paste_markdown | boolean | false | Start with paste-as-Markdown mode on. While on, a paste is read as Markdown and inserted as formatting — see Pasting Markdown |
| speech_to_text | boolean | true | Enable Speech to Text and Dictate toolbar buttons (requires Web Speech API: Chrome, Edge, Safari) |
| setup | (editor) => void | - | Callback invoked before init — use to register custom buttons |
Toolbar Toggle
The toolbar supports a || (double pipe) separator to split buttons into a primary row and collapsible overflow rows. When || is present, a toggle button (…) appears at the far right of the primary row. Clicking it shows or hides the overflow buttons.
const editor = new HTMLEditor(container, {
// Buttons before || are always visible; buttons after || are toggled
toolbar: 'bold italic underline strikethrough | fontfamily fontsize || alignleft aligncenter alignright | forecolor backcolor | undo redo',
});The default FULL_TOOLBAR and BASIC_TOOLBAR presets include || so the toggle button appears out of the box. The overflow rows start collapsed.
If your custom toolbar string contains no ||, all buttons render in a single flat toolbar with no toggle button (backwards compatible).
API Reference
Methods
getContent(): string- Get HTML content (applies theformat_empty_linesserialization pass; thechangeevent payload is identical)setContent(html: string): void- Set HTML content. Backslash-escaped closing tags (<\/p>, produced by hosts that pass HTML through PHPjson_encodeand similar/→\/encoders) are repaired to real tags before parsing, matching TinyMCE's lenient parser, and theformat_empty_linesimport pass is applied. Insignificant whitespace between block elements is collapsed per the HTML spec, so pretty-printed (indented, multi-line) source imports the same way a browser would render it — see Importing pretty-printed HTMLinsertContent(html: string): void- Insert HTML at cursor (same<\/repair, whitespace handling, andformat_empty_linesimport pass assetContent, so inserting a fragment produced bygetContent()does not double its blank lines)execCommand(cmd: string, ui?: boolean, value?: any): boolean- Execute editor commandisPasteMarkdownEnabled(): boolean- Whether pastes are currently read as MarkdownsetPasteMarkdown(on: boolean): void- Turn paste-as-Markdown on or off. Per editor instance and not remembered between sessions — see Pasting MarkdowngetFontFamily(): string- Font family in effect at the cursor (inline override → block font → configured default). Returns''when the selection spans more than one familygetFontSize(): string- Font size in effect at the cursor (same resolution order). Returns''when the selection spans more than one size, and inside a heading with no inline override (headings size by level)isDirty(): boolean- Check if content has changedsetDirty(state: boolean): void- Set dirty statesetReadOnly(state: boolean): void- Toggle read-only mode (disables editing and dims the toolbar)isReadOnly(): boolean- Check if the editor is read-onlyfocus(): void- Focus the editorhasFocus(): boolean- Check if editor has focussetLanguage(code: string): void- Change UI language at runtime (rebuilds toolbar)getTipTap(): Editor | null- Access the underlying TipTap editor instancegetConfig(): EditorConfig- Get the resolved editor configurationisBasicEditor(): boolean- Check if the editor is in basic modeon(event, callback): void- Subscribe to an editor eventoff(event, callback): void- Unsubscribe from an editor eventfire(event, ...args): void- Fire an editor event manuallydestroy(): void- Clean up and remove editor
Events
init- Editor initializedchange- Content changed (debounced). The payload is byte-identical togetContent(), so saving the event's HTML and pollinggetContent()produce the same resultdirty- Dirty state changedfocus- Editor focusedblur- Editor blurredlanguagechange- UI language changed (receives language code)templatechange- A template was applied (receives theTemplateobject)pastemarkdownchange- Paste-as-Markdown mode was turned on or off (receives the new boolean). Fires only on an actual change, so a host can save the user's choice
Supported Commands
The following TinyMCE-compatible commands are supported:
mceTogglePasteMarkdown- turn paste-as-Markdown mode on or offbold,italic,underline,strikethroughfontname,fontsize,lineheightforecolor,hilitecolor,backcolor— pass''or'none'as the value to clear the color instead of setting onejustifyleft,justifycenter,justifyright,justifyfullinsertunorderedlist,insertorderedlistindent,outdent— context-aware, see Lists & Indentationundo,redoremoveformatmceremoveeditor- Destroys the editor instance
Templates
The editor supports a template dropdown that lets users insert predefined HTML snippets. Enable it with includeTemplates: true and provide a templates array.
Template interface
interface Template {
id?: number | string; // Optional identifier
title: string; // Display name in the dropdown
description?: string; // Tooltip/description shown in the dropdown
content: string; // HTML content to insert
}Configuration
const editor = new HTMLEditor(container, {
includeTemplates: true,
templates: [
{
id: 1,
title: 'Greeting',
description: 'A simple greeting message',
content: '<h2>Hello!</h2><p>Thank you for reaching out.</p>',
},
{
id: 2,
title: 'Meeting Notes',
description: 'Template for meeting minutes',
content: '<h2>Meeting Notes</h2><p><strong>Date:</strong> [date]</p><h3>Agenda</h3><ol><li>Item 1</li></ol>',
},
],
// Make sure 'template' appears in the toolbar string
toolbar: 'bold italic | template | undo redo',
});Listening for template changes
When a user selects a template, the templatechange event fires with the full Template object:
editor.on('templatechange', (template) => {
console.log('Applied template:', template.title);
console.log('Template ID:', template.id);
console.log('Content:', template.content);
});This event fires in addition to the standard change event, so consumers can distinguish a template insertion from regular edits and react accordingly (e.g. populate form fields, trigger a save, or log analytics).
Importing pretty-printed HTML
Template bodies are often stored as they were authored — indented, one block per line — rather than minified. setContent() and insertContent() collapse the insignificant whitespace between block elements exactly as the HTML spec (and a browser) does, so the source below imports as a two-item list and nothing else:
<div>Last Week</div>
<ul>
<li>
<div>Completed the following</div>
</li>
<li>
<div>Fixed the following</div>
</li>
</ul>The newlines and tabs between <ul> and <li> are dropped rather than becoming empty list items, and the blank lines between top-level blocks do not become empty blocks. Whitespace that is significant is untouched: text inside a block, a non-breaking space ( ), and the contents of a <pre>/code block all survive verbatim. Pasting with Ctrl+V goes through a separate clipboard parser and is unaffected either way.
Importing wrapped HTML
Template bodies also tend to arrive swaddled in container <div>s — mail clients and CMSes nest several around the real content. The editor's schema has one block node per line and a block cannot contain a block, so that nesting cannot survive the parse. On import, a <div> or <p> that contains only block-level children and no text of its own is dissolved and its children hoisted, rather than being left behind as an empty block:
<!-- imported -->
<div><div><div>
<div>Hello</div>
<div>World</div>
</div></div></div>
<!-- becomes two lines, not two blank lines followed by two lines -->
<div>Hello</div>
<div>World</div>Inherited style on a dissolved wrapper (font-family, font-size, font-weight, font-style, color, line-height, text-align, text-indent, direction) is carried down to the children it wrapped, unless a child already states that property. A body wrapped in a single <div style="font-family:Georgia;font-size:10pt"> therefore keeps its font on every line. Box properties (border, background, padding, margin) describe the wrapper itself and cannot be re-expressed on n children, so they are dropped.
Blocks that own their nesting are never dissolved: a <blockquote>, <li> or table cell holding blocks is a real node in the schema, and the signature container (<div id="signature">) is left intact. A genuinely empty <div></div> is a blank line the author typed, not a wrapper, and is preserved.
Placeholder links
An <a href=""> — a link whose target the author has not filled in yet, or that an upstream sanitizer has blanked — is preserved as a link on import, matching TinyMCE and CKEditor. URI validation is otherwise unchanged: a javascript: or data: target is still rejected and its anchor unwrapped to plain text.
Toolbar Buttons
All built-in toolbar button names that can be used in the toolbar config string:
| Button | Description |
|--------|-------------|
| bold | Toggle bold |
| italic | Toggle italic |
| underline | Toggle underline |
| strikethrough | Toggle strikethrough |
| subscript | Toggle subscript |
| superscript | Toggle superscript |
| bullist | Toggle bullet list |
| numlist | Toggle numbered list |
| outdent | Decrease indent (see Lists & Indentation) |
| indent | Increase indent (see Lists & Indentation) |
| blockquote | Toggle block quote |
| fontfamily | Font family dropdown (button shows the family at the cursor) |
| fontsize | Font size dropdown (button shows the size at the cursor) |
| lineheight | Line height dropdown (1, 1.2, 1.4, 1.6, 2) |
| blocks | Block format dropdown (Paragraph, Heading 1–6; alias formatselect) |
| styles | Named styles dropdown (configurable via style_formats) |
| table | Table dropdown (insert table + row/column/cell operations) |
| hr | Insert horizontal rule |
| template | Template dropdown (requires includeTemplates: true) |
| alignleft | Align left |
| aligncenter | Align center |
| alignright | Align right |
| alignjustify | Justify text |
| forecolor | Text color picker (40 preset colors + custom hex input + Remove color) |
| backcolor | Highlight color picker (26 preset colors + custom hex input + Remove color) |
| removeformat | Clear all formatting |
| copy | Copy selection |
| cut | Cut selection |
| paste | Paste from clipboard (async Clipboard API; prefers HTML, falls back to plain text). Follows the Markdown mode when it is on |
| pastemarkdown | Toggle paste-as-Markdown mode. Not in the default toolbars — add it to your toolbar string |
| undo | Undo last change |
| redo | Redo last undo |
| image | Insert image dialog (upload file or enter URL) |
| charmap | Special character picker (currency, math, arrows, symbols, Greek, punctuation) |
| emoticons | Emoji picker with search (smileys, gestures, hearts, objects, symbols, arrows) |
| code | Open HTML source code editor dialog |
| link | Open Insert/Edit Link dialog |
| unlink | Remove the link at the cursor |
| anchor | Insert a named anchor (<a id> target) |
| codesample | Toggle code sample block |
| fullscreen | Toggle fullscreen editing mode |
| preview | Open content preview in a new window (the window carries a Content Security Policy that blocks script; images and inline styles still render) |
| searchreplace | Open Find & Replace dialog |
| speechtotext | Open Speech to Text dialog (browser support required) |
| dictate | Toggle inline dictation — inserts speech directly at cursor (browser support required) |
| ltr | Set text direction to left-to-right |
| rtl | Set text direction to right-to-left |
Default toolbars
Full toolbar (default when basicEditor: false):
bold italic underline strikethrough subscript superscript | blocks styles | bullist numlist outdent indent blockquote | fontfamily fontsize | lineheight alignleft aligncenter alignright alignjustify | forecolor backcolor | removeformat copy cut paste | undo redo | image table charmap emoticons hr | fullscreen preview | code link unlink anchor codesample | ltr rtl | searchreplaceBasic toolbar (when basicEditor: true):
bold italic underline strikethrough subscript superscript | bullist numlist outdent indent | fontfamily fontsize blockquote | lineheight alignleft aligncenter alignright alignjustify | forecolor backcolor | removeformat copy cut paste | undo redo | charmap emoticons | link unlink | ltr rtl | searchreplaceImage Upload
The image button opens a two-tab dialog:
- Upload — Drag-and-drop or browse for a local file. Supports JPEG, PNG, GIF, WebP, and SVG.
- URL — Enter an image URL directly.
If images_upload_url is configured, files are sent as multipart/form-data to that endpoint. The server response must be JSON containing a location, url, or link field with the resulting image URL. When no upload URL is set, images are embedded as base64 data URIs.
SVG files are automatically sanitized — dangerous elements (<script>, <foreignObject>, <animate*>) and event-handler attributes are stripped before insertion.
const editor = new HTMLEditor(container, {
images_upload_url: '/api/upload',
images_upload_credentials: true, // send cookies with CORS requests
images_upload_base_path: '/files/', // prefix for returned URLs
images_upload_max_size: 5 * 1024 * 1024, // 5 MB limit
images_upload_headers: {
'X-CSRF-Token': token,
},
});Restricting file types and rejecting uploads
images_file_types restricts which extensions are accepted (matched against the
file name, falling back to MIME for clipboard pastes that have no name). The
images_upload_validate hook runs before every upload — return a message to
reject the file. For drag-drop and clipboard-paste uploads there is no dialog to
show the message in, so rejections and failures are routed to images_upload_error
(supply your own alert/notification — the CKEditor simpleuploads newAlert flow).
const editor = new HTMLEditor(container, {
images_upload_url: '/api/upload',
images_file_types: 'jpg,jpeg,png,gif,bmp', // CKEditor simpleuploads parity
images_upload_validate: (file) =>
file.size === 0 ? 'Empty files are not allowed' : null,
images_upload_error: (message) => myApp.showAlert(message),
});Source Code Editor
The code toolbar button opens a modal dialog for viewing and editing the raw HTML source of the editor content. The dialog features a full-size monospace textarea with the current HTML, Cancel and Save buttons, and supports Escape to close and Tab to indent.
The dialog inherits the active skin theme — oxide, oxide-dark, confab, and confab-dark are all supported automatically.
Insert/Edit Link
The link toolbar button opens a modal dialog for inserting or editing hyperlinks with the following fields:
- URL — the link destination
- Text to display — the visible link text (pre-populated from selection)
- Title — the HTML
titleattribute (shown as a tooltip on hover) - Open link in… — dropdown to choose between Current window or New window (
target="_blank")
When editing an existing link, all fields are pre-populated from the current link attributes. With the caret on a link and nothing selected, the dialog edits that whole link, text included, and Save updates it in place. Clearing the URL and saving removes the link. The dialog inherits the active skin theme. The separate unlink toolbar button removes the link at the cursor without opening the dialog.
Typing after a link: after a paste or a link inserted from the dialog leaves the caret at the end of a link, the next text typed is plain, so a pasted URL does not swallow the words that follow it. This covers pasted URLs and email addresses, pasted HTML or Markdown that ends in a link (Ctrl+V or the Paste button), editor.insertContent() (and so the Templates dropdown), and dialog inserts. Otherwise links behave as in the browser's own editing: put the caret at the end of a link and type, and the link extends; select link text and retype it, and the link is kept.
Pasting a URL: a URL or email address pasted at the caret is linked. Pasted over selected text (or a selected inline item such as a mention) within one paragraph, it turns that selection into a link instead of replacing it. Over a larger selection (all content selected with Ctrl+A, or a selection spanning several paragraphs), it replaces the selection like any other paste, and the pasted URL is linked. (Text copied from inside the editor pastes exactly as it was copied, linked or not.)
Clicking a link in the editor places the caret; it does not open the link.
URL normalization: a URL entered without a scheme that looks like a bare domain (e.g. www.example.com or example.com/path) is saved with http:// prepended, so it resolves as an external link instead of a broken relative path. URLs that already carry a scheme (https:, mailto:, tel:, ftp:, …), anchors (#section), absolute/relative paths (/, ./, ../), and protocol-relative URLs (//host) are left exactly as typed.
Named Anchors
The anchor toolbar button opens a dialog to insert a named anchor — an <a id="name"> target for in-page linking. The anchor name is required and may not contain spaces. Existing anchors in loaded content (<a id> with no href) are preserved through the editor's parse/serialize cycle.
// Programmatic insertion via the TipTap command:
editor.getTipTap()?.commands.setAnchor('section-1');Tables
The table toolbar button is a dropdown that inserts a table and edits the one under the cursor:
- Insert table — inserts a 3×3 table with a header row.
- Insert/delete row, Insert/delete column, Merge cells, Split cell, Toggle header row, Delete table.
Tables are resizable by dragging column borders.
Lists & Indentation
Increase / decrease indent
indent and outdent — the toolbar's Increase indent / Decrease indent buttons, execCommand('indent' | 'outdent'), and the Tab / Shift+Tab keys — all resolve to the same behavior, chosen by what is under the cursor:
| Context | Increase indent | Decrease indent |
| --- | --- | --- |
| Code block | Inserts a literal tab | Nothing |
| List item with a previous sibling | Nests it one level deeper | Reduces its own indent, then un-nests it one level |
| First list item at its level | Adds a margin-left to the <li> | Reduces that margin, then lifts the item out of the list |
| Paragraph, heading | Adds a margin-left in 40px steps (max 400px) | Removes one step |
Inside a list the indent is written to the <li> rather than to the paragraph inside it, so the bullet or number moves with the text. Because it is an inline margin-left, it survives in exported HTML — in a mail client, say — without the editor's stylesheet. In a table, Tab still moves between cells; the toolbar buttons indent the paragraph in the cell.
Ordered list numbering
An ordered list's numbering style is preserved rather than normalized to 1, 2, 3. <ol type="A">, type="a", type="I", type="i" and start="5" round-trip through setContent() / getContent() and render as written. A list that states its style with CSS instead (<ol style="list-style-type:upper-alpha">) is read the same way and exported as <ol type="A">.
Lists that say nothing keep the editor's nesting defaults: decimal, then lower-alpha, then lower-roman for ordered lists; disc, circle, then square for bullets.
Pasting a list
Lists pasted from Word, Outlook, Google Docs, another editor or a web page are normalized on the way in:
- Indentation is dropped. Those sources position a list with inline
margin-left/padding-left/text-indentfrom their own page layout. Carried over, that stacks on top of the editor's list indent, so a pasted list sits a level further right than one built with the toolbar. Nesting is structural (<ol><li><ol>), so nothing is lost by dropping it. Indentation on non-list blocks (paragraphs, blockquotes) is left alone. - A list that is already a real
<ol>/<ul>stays one list. Word and Outlook email HTML often marks up a genuine list whose<li>s also carry Word'sMsoListParagraphclass andmso-listmetadata; those items are cleaned in place instead of being wrapped in a second list. - Word's numbering style is kept.
mso-level-number-format(andmso-level-start-at) become the list'stypeandstart, so a lettered or roman Word list is not renumbered. When the clipboard carries no@listrules at all — common — the style is inferred from the marker text Word inlines (A.,iv.,1.,·), which also keeps a numbered list from arriving as a bullet list.
Word/Excel cleaning as a whole can be turned off with paste_from_office: false; list indentation is normalized either way.
Pasting Markdown
Chat apps, AI assistants and README files hand out Markdown, which pastes into an editor as the raw #, * and | characters. Turn on paste-as-Markdown mode and a paste is converted to real formatting instead.
The mode is off by default and stays on until it is turned off, like TinyMCE's Paste as text. Turn it on with the pastemarkdown toolbar button, paste_markdown: true, editor.setPasteMarkdown(true), or editor.execCommand('mceTogglePasteMarkdown').
const editor = new HTMLEditor(el, {
toolbar: 'bold italic | bullist numlist | pastemarkdown paste',
paste_markdown: false,
});
// Hosts that want to remember the choice can save it and pass it back:
editor.on('pastemarkdownchange', (enabled) => localStorage.setItem('md-paste', String(enabled)));The button is not in the default toolbars, so existing toolbars are unchanged; add pastemarkdown to your own toolbar string. While the mode is on the button is highlighted, carries a dot and reports aria-pressed="true", because a mode left on by mistake quietly changes every paste.
What is converted
Headings, bold, italic, strikethrough, inline code, links, images, bullet/ordered/nested lists, blockquotes, horizontal rules, fenced code blocks (with their language) and GFM tables.
- A single newline is a line break.
line one⏎line twopastes as two lines, not one run-on paragraph, which matches how chat and AI output is written. A blank line still starts a new paragraph. - Task lists become plain bullets.
- [ ] itempastes as a bullet readingitem; the editor has no checkbox node. - Raw HTML in the Markdown is shown as literal text. Pasting
<u>x</u>gives you the visible characters<u>x</u>, not an underline. This keeps a paste from injecting markup, including the editor's own mention and signature elements. - Large unlabelled code blocks are treated as plain text rather than auto-detected, which keeps a big paste from stalling the editor.
What is left alone
The mode only affects pasting, and even then only some pastes:
- The clipboard's HTML is ignored while the mode is on. Code editors and web pages put syntax-highlighted HTML on the clipboard beside the raw text; converting that would paste coloured code instead of formatting.
- Copying from a code editor converts too. VS Code (and editors that copy the way it does) marks the clipboard as code, which the editor would otherwise paste as a code block. While the mode is on, Markdown conversion takes precedence; with the mode off, that code-block behaviour is unchanged.
- Images and files paste and upload exactly as before.
- Inside a code block the Markdown stays literal.
- A URL pasted over selected text within a paragraph still turns that text into a link (see Insert/Edit Link).
- Drag-and-drop is never converted — only pastes are.
- Very large or pathological Markdown (over 256 KB, or a single paragraph over 5,000 characters) is pasted unconverted rather than risking a frozen tab.
With the mode off, pasting is unchanged. Note that TipTap's own paste rules already turn some inline syntax (**bold**, *italic*) into formatting as you paste — that is not this feature, and it applies either way. Block syntax such as # Heading or - item only converts while the mode is on.
Security
Pasted Markdown cannot run script in the editor: raw HTML is escaped to text, the converted HTML is parsed and sanitized in an inert document (never through innerHTML on the live page) and links are limited to the schemes TipTap allows, so javascript: targets lose the link and keep their text. Images do load from their URLs when pasted, exactly as they do for an HTML paste, so a remote image reveals the reader's IP to that server. There is deliberately no public "Markdown to HTML string" helper, because that invites host code to write it back into a page with innerHTML; hosts that need to insert Markdown use pasteMarkdownText(view, text), which inserts through the editor.
Block & Style Formats
The
blocksbutton (aliasformatselect) is a block-format dropdown — Paragraph and Heading 1–6 by default. Customize it withblock_formats(TinyMCE syntax):const editor = new HTMLEditor(container, { block_formats: 'Paragraph=p;Title=h1;Subtitle=h2', });The
stylesbutton is a named-styles dropdown driven bystyle_formats(CKEditorstylesSet-compatible). Each entry applies a block element and/or inline color/background/class to the selection:const editor = new HTMLEditor(container, { style_formats: [ { title: 'Blue Title', block: 'h3', styles: { color: 'Blue' } }, { title: 'Marker', inline: 'span', styles: { 'background-color': 'Yellow' } }, { title: 'Callout', inline: 'span', classes: 'callout' }, ], });Note: block elements map to headings/paragraph,
color/background-colormap to the editor's text-color/highlight marks, andclassesapply a CSS class to the selection. Arbitrary element wrapping from CKEditor's stylesSet (e.g.big,tt,cite) is not supported by the underlying TipTap schema.
Color Pickers
The forecolor (text) and backcolor (highlight) pickers have different default
palettes, because a color that reads well behind text often does not read well as
text. Both are 10 swatches wide:
- Text (
forecolor) — 40 colors: a grayscale ramp, the saturated hues, and two rows of darker shades. Hue columns are red berry, red, orange, yellow, green, cyan, cornflower blue, blue, purple, magenta. - Highlight (
backcolor) — 26 colors: a grayscale ramp, the saturated hues, and the pale tints that work as marker colors but are illegible as font colors.
Both pickers also offer a custom hex input and a Remove color entry, which clears
only that color and leaves other formatting on the selection intact (unlike
removeformat, which strips everything).
Override either palette with color_map_foreground / color_map_background, or both
at once with color_map. A per-picker key wins over color_map. Both TinyMCE's flat
form and an array of ColorOption objects are accepted:
const editor = new HTMLEditor(container, {
// TinyMCE flat form: alternating value and label
color_map_foreground: [
'#000000', 'Black',
'#1155CC', 'Dark Blue',
'#38761D', 'Dark Green',
],
// ColorOption[] form
color_map_background: [
{ value: '#FFF2CC', label: 'Light Yellow' },
{ value: '#D9EAD3', label: 'Light Green' },
],
});Colors can also be set programmatically, including clearing them:
editor.execCommand('forecolor', false, '#1155CC');
editor.execCommand('forecolor', false, ''); // clear the text color
editor.execCommand('backcolor', false, 'none'); // clear the highlightNote: swatch labels are used verbatim as tooltips and are not run through the translation layer.
Font Family & Size
The editor inlines a default font directly on the block elements of the
exported HTML so content keeps its font when rendered outside the editor (for
example, an email body opened in another client that doesn't load the editor's
CSS). Set the base font with fontName / fontSize:
const editor = new HTMLEditor(container, {
fontName: 'Georgia, serif',
fontSize: '14pt',
});With the config above, getContent() produces blocks that carry the font inline:
<p style="font-family: Georgia, serif; font-size: 14pt;">Hello</p>Defaults when the options are omitted: arial, helvetica, sans-serif and 12pt.
font-familyis inlined on paragraphs and headings.font-sizeis inlined on paragraphs only — headings keep their level-based sizing (h1= 2em,h2= 1.5em, …), which a forced default would otherwise clobber.
Per-selection changes still work. Selecting text and picking a font or size
from the fontfamily / fontsize toolbar dropdowns (or execCommand('fontname', …)
/ execCommand('fontsize', …)) produces an inline <span> that overrides the
block default for just that text — so a single paragraph can mix fonts and sizes:
<p style="font-family: Georgia, serif; font-size: 14pt;">
this is <span style="font-size: 24pt;">A BIG font</span>, and this is a small font
</p>Where a font change is stored
A font change has to be recorded somewhere that survives until the user actually types, so the editor picks the target based on the selection:
| Selection when the font is picked | Where it goes | Result |
| --- | --- | --- |
| Text is selected | Inline <span> mark | Overrides the block default for that text only |
| Cursor in an empty block (e.g. a fresh, empty message body) | The block's own font-family / font-size | Persists; everything typed into that block gets it |
| Cursor inside existing text | Stored mark, as in any editor | Applies to what you type next, until you move the cursor |
The empty-block rule matters: an inline mark has no text to attach to there, so
it can only be parked as a ProseMirror stored mark — which is discarded by the
very next transaction that moves the selection or changes the document. Clicking
into the body, or a host app calling setTextSelection, would silently drop the
font before a single character was typed. Writing it to the block instead makes
the choice stick and puts it in the exported HTML.
Use getFontFamily() / getFontSize() to read back the font in effect at the
cursor, wherever it happens to be stored.
The toolbar dropdowns show the current font. The fontfamily and fontsize
buttons display the value in effect at the cursor — "Times New Roman", "12pt" —
and update as the caret moves, rather than showing the static words "Font" and
"Font size". A family is shown by its configured name (the label side of
font_family_formats); a font that isn't in the configured list, such as one
pasted from another editor, is shown by its first family name. The labels are
width-constrained and ellipsized to keep the toolbar from reflowing as the caret
moves, with the full text on each button's title.
Where no single value applies, the button falls back to the generic word. That happens for a selection spanning two different fonts or sizes, and for font size inside a heading (headings size by level and carry no block font-size).
This is handled by the exported BlockFontStyle extension (block defaults and the
setBlockFontFamily / setBlockFontSize commands) layered with the inline
FontSize mark and TipTap's FontFamily mark (per-selection overrides).
Read-Only Mode
Start the editor read-only with readonly: true, or toggle it at runtime. Read-only mode disables editing and dims/blocks the toolbar.
const editor = new HTMLEditor(container, { readonly: true });
editor.setReadOnly(false); // make editable
editor.setReadOnly(true); // back to read-only
console.log(editor.isReadOnly());Enter Behavior (forced_root_block)
By default, pressing Enter creates a new <p> block. Set forced_root_block: 'div' for CKEditor ENTER_DIV parity, so new blocks (and the serialized output) use <div> instead. The <div id="signature"> signature block is still preserved in either mode.
const editor = new HTMLEditor(container, { forced_root_block: 'div' });Search & Replace
The searchreplace toolbar button (or Ctrl/Cmd+F) opens a Find & Replace dialog with:
- Case-sensitive matching toggle
- Whole-word matching toggle
- Find next / Find previous navigation
- Replace current match
- Replace all matches
Dialog keyboard shortcuts: Enter = find next, Shift+Enter = find previous, Escape = close.
Keyboard Shortcuts
| Shortcut | Action | |----------|--------| | Ctrl/Cmd+B | Toggle bold | | Ctrl/Cmd+I | Toggle italic | | Ctrl/Cmd+U | Toggle underline | | Ctrl/Cmd+Z | Undo | | Ctrl/Cmd+Shift+Z | Redo | | Ctrl/Cmd+F | Open Find & Replace | | Tab | Always handled (never escapes the editor). Nests the current list item (or, for an item that can't nest, indents its content), inserts a tab in a code block, moves to the next cell in a table, or adds a left margin to the current paragraph/heading | | Shift+Tab | Inverse of Tab: reduces the current block's left margin, lifts the current list item, or moves to the previous table cell | | Esc, then Tab | Moves focus out of the editor to the next focusable element (Shift+Tab → previous), so Tab is not a keyboard trap. Esc arms this for the next key press only; any other key cancels it |
Localization
The editor ships with built-in translations for 31 languages. Set the language at construction or switch dynamically at runtime.
Setting the language
// At construction
const editor = new HTMLEditor(container, { language: 'fr' });
// Change at runtime — toolbar and dialogs update immediately
editor.setLanguage('ja');
// Listen for language changes
editor.on('languagechange', (code) => {
console.log('Language changed to:', code);
});Custom translations
If you need translations the built-in locales don't cover, you can provide your own function. A custom setTranslate() call takes priority over the built-in locale at construction time.
import { setTranslate, TRANSLATION_KEYS } from '@mdaemon/html-editor';
// See all keys that need translating
console.log(TRANSLATION_KEYS);
// Provide a custom translation function
setTranslate((key) => myCustomLookup(key));Supported languages
| Code | Language | Code | Language |
|------|----------|------|----------|
| en | English | nl | Nederlands |
| ar | العربية | nb | Norsk bokmål |
| ca | Català | pl | Polski |
| zh | 中文 | pt | Português |
| cs | Česky | ro | Româna |
| da | Dansk | ru | Русский |
| en-gb | English (UK) | sr | Srpski |
| fi | Suomi | sl | Slovenščina |
| fr | Français | es | Español |
| fr-ca | Canadien français | sv | Svenska |
| de | Deutsch | zh-tw | 繁體中文 (Taiwan) |
| el | Ελληνικά | th | ไทย |
| hu | Magyar | tr | Türkçe |
| id | Bahasa Indonesia | vi | Tiếng Việt |
| it | Italiano | | |
| ja | 日本語 | | |
| ko | 한글 | | |
Discovering available locales
import { availableLocales, getLocale } from '@mdaemon/html-editor';
console.log(availableLocales); // ['en', 'ar', 'ca', ...]
console.log(getLocale('de')); // { Bold: 'Fett', Italic: 'Kursiv', ... }Global Utilities
These functions are exported from the package and operate globally (affecting all editor instances).
Translation
import { setTranslate, getTranslate, resetTranslate, createTranslateFunction } from '@mdaemon/html-editor';
// Override translations for all editors
setTranslate((key) => myLookup(key));
// Get the current translate function
const t = getTranslate();
// Reset to built-in locale translations
resetTranslate();
// Create a translate function for a specific locale
const translateDe = createTranslateFunction('de');
console.log(translateDe('Bold')); // 'Fett'File source resolver
The file source resolver lets you transform image src attributes (e.g. to prepend a CDN URL or resolve relative paths).
import { setGetFileSrc, getGetFileSrc } from '@mdaemon/html-editor';
setGetFileSrc((path) => `https://cdn.example.com${path}`);
const resolver = getGetFileSrc();
console.log(resolver('/img/photo.jpg')); // 'https://cdn.example.com/img/photo.jpg'Exported constants
import { fontNames, CHAR_MAP, EMOJI_CATEGORIES, TRANSLATION_KEYS } from '@mdaemon/html-editor';
// fontNames — default semicolon-separated font family string
// CHAR_MAP — array of character categories for the character map dialog
// EMOJI_CATEGORIES — array of emoji categories for the emoji picker
// TRANSLATION_KEYS — array of all translation key stringsCustom Toolbar Button API
The setup callback receives a TinyMCE-compatible editor API. Use ui.registry.addButton() to register custom buttons.
const editor = new HTMLEditor(container, {
toolbar: 'bold italic | myButton | undo redo',
setup: (ed) => {
ed.ui.registry.addButton('myButton', {
tooltip: 'My Custom Button',
text: 'Click Me', // text label on the button
icon: '/icons/custom.svg', // image URL (alternative to text)
disabled: false,
onSetup: (api) => {
// Called when the button is created
// api.setActive(true) — toggle active state
// api.setEnabled(false) — disable the button
// Return a teardown function (optional)
return () => { /* cleanup */ };
},
onAction: (api) => {
ed.insertContent('<p>Custom content!</p>');
},
});
},
});ToolbarButtonAPI
The api object passed to onSetup and onAction:
| Method | Description |
|--------|-------------|
| isEnabled() | Whether the button is currently enabled |
| setEnabled(enabled) | Enable or disable the button |
| isActive() | Whether the button is in an active/pressed state |
| setActive(active) | Toggle the active/pressed visual state |
Theming
Light and dark themes
Set skin to switch between light and dark chrome:
const editor = new HTMLEditor(container, {
skin: 'oxide-dark', // dark toolbar and dialogs
content_css: 'dark', // dark content area
});Confab skin
The confab and confab-dark skins integrate with the WorldClient theming system. They use CSS custom properties from the host application so the editor automatically adapts when the app theme changes.
The confab skins also replace the default text/emoji toolbar icons with clean SVG line icons for a more polished look.
// Light mode
const editor = new HTMLEditor(container, {
skin: 'confab',
content_css: 'confab',
});
// Dark mode
const editor = new HTMLEditor(container, {
skin: 'confab-dark',
content_css: 'confab-dark',
});The confab skins expect the following CSS custom properties to be defined by the host application:
| Variable | Purpose |
|----------|---------|
| --theme-primary | Primary brand color (buttons, active states) |
| --theme-primary-hover | Hover state for primary color |
| --color-confab-gray-50 to --color-confab-gray-900 | Gray scale for backgrounds, text, borders |
| --color-confab-500 | Focus ring color |
| --color-dark-bg-primary | Dark mode primary background |
| --color-dark-bg-secondary | Dark mode toolbar/panel background |
| --color-dark-bg-tertiary | Dark mode inset/recessed background |
| --color-dark-bg-hover | Dark mode hover state |
| --color-dark-text-primary | Dark mode primary text |
| --color-dark-text-secondary | Dark mode secondary text |
| --color-dark-text-muted | Dark mode muted text |
| --color-dark-border | Dark mode border color |
All variables include fallback values so the editor remains usable without the host CSS, though colors may not match the intended design.
Custom content styles
Inject additional CSS into the editor content area:
const editor = new HTMLEditor(container, {
content_style: 'body { font-family: Georgia, serif; font-size: 16px; }',
});CSS classes
The editor DOM uses BEM-style classes you can target for further customization:
.md-editor— outer wrapper.md-editor-fullscreen— applied in fullscreen mode.md-editor-oxide/.md-editor-oxide-dark— oxide theme variant.md-editor-confab/.md-editor-confab-dark— confab theme variant.md-toolbar— toolbar container.md-toolbar-sticky— sticky toolbar.md-toolbar-primary/.md-toolbar-overflow— primary and collapsible toolbar rows.md-toolbar-btn/.md-toolbar-btn-active— toolbar buttons
Browser Support
- Chrome (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)
Development
Clone the repo and install dependencies with npm install, then use:
npm run build # production build → dist/ (ES, CJS, UMD, .d.ts, styles.css)
npm run build:dev # development build (unminified, sourcemaps)
npm run dev # rebuild on change (watch mode)
npm test # run the Jest suite
npm run test:dist # build, then smoke-test the built UMD bundle
npm run typecheck # tsc --noEmit (strict)
npm run lint # eslint srcDemos
npm run demo # live demo from TypeScript source (Vite dev server, HTTPS)
npm run demo:umd # build, then serve the UMD/script-tag demo as static filesnpm run demoservestest/index.htmlthrough Vite's dev server, importing the editor directly fromsrc/. Use this for day-to-day feature work — it hot-reloads and exercises the full toolbar. It runs over HTTPS (self-signed cert) so microphone-dependent features (Speech-to-Text, Dictation) work.npm run demo:umdruns a production build and then servestest/umd.htmlas raw static files (viatest/serve-umd.mjs), loading the builtdist/index.umd.jsanddist/styles.cssexactly the way a CDN /<script>-tag consumer would. Use this to verify the UMD bundle and theMDHTMLEditorglobal.
Changelog
See CHANGELOG.md.
License
Published under the LGPL-3.0-or-later license.
Published by MDaemon Technologies, Ltd.
Simple Secure Email
https://www.mdaemon.com
