@momentum-amp/editor-react
v1.1.1
Published
Momentum AMP rich-text editor — React bindings
Maintainers
Readme
@momentum-amp/editor-react
React bindings for Momentum Editor — an email-safe rich-text editor built on Tiptap v3 / ProseMirror, designed for apps that must keep reading and writing HTML authored by a legacy rich-text editor without breaking a word of it.
import { MomentumEditor } from '@momentum-amp/editor-react';
import '@momentum-amp/editor-core/styles.css';
<MomentumEditor value={html} onChange={setHtml} />- HTML in, HTML out. No Delta, no proprietary document format.
onChangehands you a string you can store and email as-is. - Email-safe output. Inline styles only, self-describing blocks, no classes, no base64 images,
<p><br></p>blank lines — because the same string is rendered by your editor, your app views and Gmail/Outlook, which share no CSS. - Reads legacy markup from a prior editor.
ql-align-*,ql-font-*,ql-size-*,ql-indent-*, flat nested lists and{Token}merge tags all parse on load and round-trip byte-stably. - Configurable, not hard-coded. Fonts, sizes, colours, features, toolbar layout, theme tokens, labels and the upload pipeline are all props with working defaults.
- Zero UI dependencies. No Tailwind, no component library, no CSS-in-JS — one plain stylesheet driven by
--mtm-*custom properties.
Contents
- Installation
- Quickstart
- Props
- Ref API
- Events / callbacks
- Features
- Toolbar
- Theming
- Merge tags
- Image upload
- Code view
- Document passthrough
- Labels / i18n
- Keyboard shortcuts
- Examples
- HTML contract
- Requirements & dependencies
- Limitations & known considerations
Installation
npm install @momentum-amp/editor-react @momentum-amp/editor-core
# or
pnpm add @momentum-amp/editor-react @momentum-amp/editor-core
# or
yarn add @momentum-amp/editor-react @momentum-amp/editor-core@momentum-amp/editor-core is a hard dependency of this package, so it installs transitively — install it explicitly anyway, because the stylesheet lives there and you have to import it:
// once, at app root
import '@momentum-amp/editor-core/styles.css';Without that import the editor renders unstyled: no toolbar chrome, no popovers, no list markers.
Peer dependencies: react >= 17, react-dom >= 17.
Quickstart
Controlled
import { useState } from 'react';
import { MomentumEditor } from '@momentum-amp/editor-react';
import '@momentum-amp/editor-core/styles.css';
export function BodyEditor({ initialHtml }: { initialHtml: string }) {
const [html, setHtml] = useState(initialHtml);
return (
<MomentumEditor
value={html}
onChange={setHtml}
placeholder="Write your message…"
minHeight={240}
/>
);
}Uncontrolled + ref
import { useRef } from 'react';
import { MomentumEditor } from '@momentum-amp/editor-react';
import type { MomentumEditorRef } from '@momentum-amp/editor-react';
export function BodyEditor({ storedHtml, onSave }) {
const editorRef = useRef<MomentumEditorRef>(null);
const save = () => onSave(editorRef.current?.getHTML() ?? '');
return (
<>
<MomentumEditor ref={editorRef} defaultValue={storedHtml} />
<button onClick={save}>Save</button>
</>
);
}Use either value + onChange (controlled) or defaultValue (uncontrolled). Passing both makes value win.
Props
MomentumEditorProps extends MomentumEditorOptions from the core package (minus content, which is value / defaultValue here) and adds the React-only props in the first table.
React-only
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Controlled HTML. External changes are pushed into the editor; the component never echoes its own emission back, so typing is not interrupted. |
| defaultValue | string | '' | Uncontrolled initial HTML. Ignored when value is set. |
| className | string | — | Extra class on the editor root (which always carries mtm-editor-root). |
| style | CSSProperties | — | Inline style on the editor root. Size with minHeight/maxHeight, not with a CSS height — see Limitations. |
| showUploadErrors | boolean | true | Show the editor's own toast when an image upload fails. Set false when your app already surfaces onImageUploadError through its own notification system, otherwise one failure is reported twice. ref.notify() keeps working either way. |
| renderToolbarExtra | ReactNode | — | Extra controls rendered inside the toolbar (a merge-tag picker, a template selector). Hidden in document passthrough mode. |
| toolbarExtraPlacement | 'start' \| 'end' | 'end' | Which end of the toolbar renderToolbarExtra sits at. |
Content & state
| Prop | Type | Default | Description |
|---|---|---|---|
| placeholder | string | '' | Placeholder text for an empty editor. Also settable via labels.placeholder. |
| readOnly | boolean | false | Renders content without editing. The toolbar is hidden entirely in this mode. |
| allowCodeView | boolean | false | Adds a toolbar switch that flips the editor into a raw-HTML textarea. See Code view. |
Formatting & schema
| Prop | Type | Default | Description |
|---|---|---|---|
| features | EditorFeature[] | all 21, in the order listed in Features | Ordered list. Presence enables the feature's schema nodes/marks; array order positions its toolbar controls when toolbar is 'auto'. ⚠️ This is the editor's schema, not a button list — see the warning in Features. |
| fonts | FontDef[] | DEFAULT_FONTS (8 families) | Font whitelist. { value, label, css? } — value is what lands in the HTML, label is the picker text, css is the in-editor preview stack. |
| sizes | string[] | ['10px', '20px', '32px'] | Font-size whitelist. The picker always offers an unset option in addition. |
| colors | string[] | DEFAULT_COLORS (35 swatches, 7×5) | Palette for the text-colour and highlight pickers. |
| linkProtocols | string[] | ['http', 'https', 'mailto', 'tel'] | URL scheme whitelist. Anything else is rejected on link insert and stripped from stored content on load. |
| tagSyntax | 'single' \| 'double' | 'single' | Merge-tag delimiters: {Token} or {{Token}}. Governs both parsing and serialization. |
| mergeTags | MergeTagDef[] | [] | { label, value }[]. Enables chip rendering with friendly labels; see Merge tags. |
Toolbar & appearance
| Prop | Type | Default | Description |
|---|---|---|---|
| toolbar | 'auto' \| 'full' \| 'standard' \| 'minimal' \| string[][] \| false | 'auto' | 'auto' derives the layout from features; a preset name uses a fixed layout; string[][] is your own grouping; false hides the toolbar. See Toolbar. |
| theme | ThemeTokens | {} | Design tokens applied as --mtm-* custom properties on the root. See Theming. |
| labels | Partial<Labels> | DEFAULT_LABELS | Every user-visible string. Shallow-merged over the defaults. See Labels / i18n. |
Sizing
| Prop | Type | Default | Description |
|---|---|---|---|
| minHeight | number (px) | 120 | Minimum content-area height. |
| maxHeight | number \| null (px) | null | Height at which the content area starts scrolling. null = unbounded. |
| autoGrow | boolean | true | Content area grows with the content (up to maxHeight). Set false for a fixed box at minHeight. |
Images
| Prop | Type | Default | Description |
|---|---|---|---|
| imageUpload | ImageUploadOptions | undefined | Upload wiring. Omit it and the image button is hidden — there is no base64 fallback. See Image upload. |
ImageUploadOptions:
| Field | Type | Default | Description |
|---|---|---|---|
| upload | (file: File) => Promise<string> | — | Your app performs the whole upload and resolves with the hosted URL. Wins if both shapes are set. |
| getSignedUrl | (file: File) => Promise<{ uploadUrl: string; publicUrl: string }> | — | The package PUTs the file to uploadUrl with Content-Type: file.type, then embeds publicUrl. |
| maxSizeMB | number | 10 | Rejects larger files with labels.imageTooLarge. |
| onError | (error: Error) => void | — | Upload-scoped error hook, called alongside onImageUploadError. |
Ref API
MomentumEditorRef (the core MomentumEditor handle plus notify):
| Method | Signature | Description |
|---|---|---|
| getHTML | () => string | Email-safe serialized HTML — the same string onChange emits. In document-passthrough mode, the stored HTML verbatim. |
| setHTML | (html: string) => void | Replace the content, running the legacy compat parse first. Does not fire onChange. |
| insertMergeTag | (tag: string) => void | Insert a merge-tag chip at the cursor. Pass the raw token value ('Contact.FirstName'), not the delimited form. |
| insertText | (text: string) => void | Insert plain text at the cursor. |
| focus | () => void | Focus the editor. |
| isEmpty | () => boolean | Whether the document has no content. |
| destroy | () => void | Destroy the underlying instance. Unmounting already does this — you rarely need it. |
| notify | (message: string, type?: 'error' \| 'info') => void | Show a toast inside the editor, using the same UI as upload errors. type defaults to 'error'. |
| tiptap | Editor | Escape hatch to the raw Tiptap instance. Unstable — do not build product features on it. |
The ref resolves after mount (the editor is created on mount for SSR safety), so read it from an event handler or an effect, not during the first render.
Events / callbacks
| Callback | Signature | Fires when |
|---|---|---|
| onChange | (html: string) => void | Content changed. Receives fully serialized, email-safe HTML — not an intermediate representation. |
| onFocus | () => void | The content area gains focus. |
| onBlur | () => void | The content area loses focus. |
| onImageUploadStart | (file: File) => void | An accepted file starts uploading (a placeholder spinner is now in the document). |
| onImageUploadSuccess | (url: string) => void | Upload finished and the <img> was inserted. |
| onImageUploadError | (error: Error) => void | Upload rejected or failed: unconfigured upload, oversize file, network/HTTP failure. Nothing is inserted. |
onChange is safe to define inline — the component reads it through a ref, so a new function identity on every render does not reload the document.
Features
features is an ordered array of enum values, not a set of boolean flags:
features={['bold', 'italic', 'underline', 'lists', 'link', 'image', 'mergeTags']}⚠️
featuresis the editor's schema, not its button listA feature you omit is formatting the editor cannot represent. Stored content using it is silently dropped on load, and the flattened version is what gets written back on the next save. To slim down the toolbar, pass
toolbarand leavefeaturesalone.
| Feature | Enables | Toolbar items contributed |
|---|---|---|
| headings | H1–H3 | heading (picker) |
| fonts | font-family from the fonts whitelist | font (picker) |
| sizes | font-size from the sizes whitelist | size (picker) |
| bold | Bold mark | bold |
| italic | Italic mark | italic |
| underline | Underline mark | underline |
| strike | Strikethrough mark | strike |
| superscript | <sup> | superscript |
| subscript | <sub> | subscript |
| blockquote | Blockquote block | blockquote |
| align | text-align on paragraphs, headings and list items | alignLeft, alignCenter, alignRight, alignJustify |
| color | Text colour | color (swatch popover) |
| highlight | Background colour | highlight (swatch popover) |
| lists | Ordered + bullet lists | orderedList, bulletList |
| indent | Indent level on paragraphs, headings and list items (3em step, clamped 0–8, matching prior stored indentation) | outdent, indent |
| link | Links, protocol-whitelisted, with an edge-aware popover editor | link |
| image | Images with drag-resize handles. Needs imageUpload to be insertable | image |
| divider | <hr> | divider |
| mergeTags | Atomic merge-tag chips | none — chips have no built-in button; add your own picker via renderToolbarExtra |
| emoji | Built-in dependency-free emoji picker (~140 curated, 6 categories) | emoji |
| clean | Clear all formatting | clean |
Keep superscript and subscript in any explicit features array even if you never expose the buttons: paste-from-Word content carries <sup>/<sub> constantly, and without the schema nodes x<sup>2</sup> is flattened to x2 on load.
Toolbar
Modes
| toolbar value | Result |
|---|---|
| 'auto' (default) | Layout derived from the features array. Order follows the array; consecutive features of the same visual category (pickers / marks / align / block / insert / utility) merge into one group. |
| 'full' | heading font size · bold italic underline strike blockquote · alignLeft alignCenter alignRight alignJustify · color highlight orderedList bulletList · link image divider emoji · clean |
| 'standard' | font size · bold italic underline strike · alignLeft alignCenter alignRight · color highlight orderedList bulletList · link image · clean |
| 'minimal' | bold italic underline · orderedList bulletList · link image |
| string[][] | Your own groups. Each inner array is one visually separated group. |
| false | No toolbar. Combine with renderToolbarExtra-free, shortcut-only editing, or drive formatting from your own UI via ref.tiptap. |
The presets are fixed lists and do not include indent, superscript or subscript. Use 'auto' or an explicit layout if you want those buttons.
Custom grouping
Layout entries accept either vocabulary — item names or feature names (which expand to that feature's items):
toolbar={[
['heading', 'font', 'size'],
['bold', 'italic', 'underline'],
['lists', 'indent'], // feature names expand: orderedList bulletList outdent indent
['link', 'image', 'emoji'],
]}An item whose feature is not enabled is skipped, so a shared layout can be reused across surfaces with different features.
Custom buttons
Register a button in the core registry (framework-free), then name it in a layout:
import { registerToolbarItem } from '@momentum-amp/editor-core';
registerToolbarItem({
name: 'clearAll',
icon: '<svg …>…</svg>', // inline SVG markup or text
title: 'Clear document',
isActive: (editor) => editor.isEmpty,
onClick: (editor) => editor.commands.clearContent(),
});
<MomentumEditor toolbar={[['bold', 'italic'], ['clearAll']]} />App-owned controls in the toolbar
import { MomentumEditor, ToolbarSelect, TOOLBAR_ICONS } from '@momentum-amp/editor-react';
<MomentumEditor
toolbarExtraPlacement="end"
renderToolbarExtra={
<ToolbarSelect
options={tagOptions}
placeholder="Insert tag"
onSelect={insertTag}
/>
}
/>ToolbarSelect (themed dropdown) and TOOLBAR_ICONS (the built-in icon set) are exported so app-added controls match the built-ins exactly.
Also exported
Toolbar, LinkPopover, EmojiPopover and their prop types are exported for hosts building a bespoke chrome around the editor. They take editor (a Tiptap instance) and options (a ResolvedOptions from core), so they are lower-level than MomentumEditor — most apps should not need them.
Theming
One prop rebrands the editor:
theme={{ primaryColor: '#381574' }}Tokens are written to the root as CSS custom properties, so anything you can express in CSS you can express here.
| Token | CSS variable | Default | Affects |
|---|---|---|---|
| primaryColor | --mtm-primary-color | #381574 | Focus/hover borders, active toolbar button, link Apply button, resize handles, blockquote bar; derives focusRing, chipBg, chipColor |
| focusRing | --mtm-focus-ring | primaryColor at 20% | Focus glow |
| borderColor | --mtm-border-color | #d9d9d9 | Editor and toolbar borders |
| controlBorderColor | --mtm-control-border-color | borderColor | Toolbar selects and swatches. transparent gives a borderless toolbar |
| toolbarDividerColor | --mtm-toolbar-divider-color | borderColor | Vertical rules between toolbar groups. transparent removes them |
| borderRadius | --mtm-border-radius | 4px | The editor's own box: root, toolbar top corners, content bottom corners |
| controlRadius | --mtm-control-radius | borderRadius | Toolbar buttons, selects, swatch. Set explicitly when borderRadius is large — a 26px-tall control at 12px radius reads as a pill |
| toolbarBg | --mtm-toolbar-bg | #fafafa | Toolbar background |
| contentBg | --mtm-content-bg | #ffffff | Content-area background |
| textColor | --mtm-text-color | inherit | Editor text colour. Set it when host CSS reaches into the editor — and note it is then also written inline into the serialized HTML |
| fontSize | --mtm-font-size | inherit | Editor base font size. Also serialized inline when set |
| lineHeight | --mtm-line-height | 1.5 | Editor base line height. Also serialized inline when set |
| linkColor | --mtm-link-color | #0b57d0 | Link colour. When set, links serialize with color + text-decoration: underline |
| chipBg | --mtm-chip-bg | 12% tint of primaryColor | Merge-tag chip and selected-option background |
| chipColor | --mtm-chip-color | primaryColor | Merge-tag chip and selected-option text |
| fontFamily | --mtm-font-family | inherit | Editor font stack |
| iconSize | --mtm-icon-size | 16px | Toolbar icon size |
textColor, fontSize, lineHeight and linkColor are the four tokens that change your stored HTML: they are written inline on serialization so the email measures the same as the editor. Set them deliberately, and keep them stable across surfaces that edit the same records.
When the editor looks wrong
The content area is a plain DOM tree, so bare element selectors in host CSS reach it. Before filing a bug, grep your app for rules like p { color: … }, ol, ul { list-style: none } (Tailwind's preflight does exactly this) or unscoped h1 { font-size: inherit }. The theme tokens exist to overpower precisely those globals; the package also ships owned list/heading/paragraph CSS and .mtm-content p, li { color: inherit; font-size: inherit } for the common cases.
Every element carries an mtm-prefixed class (mtm-editor-root, mtm-toolbar, mtm-toolbar-button, mtm-content, mtm-tag, mtm-popover, mtm-select, mtm-notice, …) if you need to reach further than the tokens allow.
Merge tags
Chips are atomic nodes that always serialize back to their literal token, regardless of the label shown:
<MomentumEditor
mergeTags={[
{ label: 'First name', value: 'Contact.FirstName' },
{ label: 'Agency name', value: 'InsuranceAgency.Name' },
]}
tagSyntax="single" // {Contact.FirstName} — 'double' for {{…}}
/>- Typing or pasting
{Contact.FirstName}converts it into a chip on load. - A chip whose
valuematches amergeTagsentry displays that entry'slabel; unknown tokens display the raw token. getHTML()writes the delimited token as plain text — rebuilt from the chip's stored value, never from its visible label.- Insert programmatically with
ref.insertMergeTag('Contact.FirstName').
There is no built-in merge-tag button: token catalogues are app data. Mount your own picker through renderToolbarExtra and call insertMergeTag.
Image upload
Images are always uploaded and embedded as hosted URLs, never base64 — Gmail and Exchange do not render base64 inline images. Toolbar insert, paste and drag-and-drop all funnel through one pipeline.
Shape 1 — your app owns the upload
<MomentumEditor
imageUpload={{
upload: async (file) => {
const res = await api.uploadImage(file);
return res.url; // hosted URL
},
maxSizeMB: 5,
}}
/>Shape 2 — presigned URL, the package does the PUT
<MomentumEditor
imageUpload={{
getSignedUrl: async (file) => {
const res = await api.signUpload({ filename: file.name, type: file.type });
return { uploadUrl: res.put_url, publicUrl: res.public_url };
},
}}
/>The built-in PUT sends Content-Type: file.type. If your presigned URLs are signed for a different content type, that request fails the signature check — use the upload shape and perform the request yourself.
Behaviour
| Situation | Behaviour |
|---|---|
| imageUpload omitted | Image button hidden; pasted/dropped image files call onImageUploadError with labels.imageUploadDisabled — never silent |
| File over maxSizeMB | Rejected with labels.imageTooLarge, nothing inserted |
| Upload in flight | A placeholder spinner node holds the spot. It is tracked by id, so typing during the upload cannot misplace the image, and it is stripped at serialize time so a save mid-upload never persists it |
| Upload fails | onImageUploadError + imageUpload.onError; a toast unless showUploadErrors={false}; nothing inserted |
| Success | <img src="…" /> at the position where the user acted, captured before the await, + onImageUploadSuccess |
| Pasted HTML containing base64 <img> | Stripped. Pasted image files upload normally |
Inserted images get corner drag handles with a live W × H label. Dimensions serialize as explicit px width/height attributes — email-safe, with no wrapper markup in the output.
Code view
<MomentumEditor allowCodeView value={html} onChange={setHtml} />Adds a toolbar switch that swaps the content area for a raw-HTML <textarea>, plus a Format/Compress toggle for the source. On switching back (or on blur) the HTML is re-parsed through the schema, so output stays email-safe no matter what was pasted in. The textarea is the single source of truth while open — nothing is mirrored or re-synced behind it.
Document passthrough
Some stored content is not editable rich text: full HTML documents, table-based designed emails, <style> blocks, MSO conditional comments, legacy <iframe> video embeds. Pushing those through a schema destroys them.
isDocumentHtml() detects them and the component switches modes automatically:
- content is kept verbatim — never parsed, never re-serialized;
- preview renders in an
<iframe sandbox="" srcDoc>(maximally restrictive; there is nodangerouslySetInnerHTMLanywhere in the package); - the formatting toolbar is replaced by the code toggle, which becomes the editing surface even when
allowCodeViewis off; renderToolbarExtrais hidden;- a banner explains the mode (
labels.documentMode); getHTML()andonChangereturn the raw string unchanged.
This is automatic and content-driven — there is no prop to force it.
Labels / i18n
Every user-visible string is overridable; unspecified keys fall back to English defaults:
labels={{ bold: 'Negrita', link: 'Insertar enlace', linkApply: 'Aplicar' }}Keys: placeholder, bold, italic, underline, strike, superscript, subscript, blockquote, link, linkPrompt, linkPlaceholder, linkApply, linkRemove, linkInvalid, unlink, image, imageUploadDisabled, imageTooLarge, imageUploadFailed, orderedList, bulletList, alignLeft, alignCenter, alignRight, alignJustify, color, highlight, divider, font, size, heading, normal, indent, outdent, clean, clearColor, mergeTag, emoji, codeView, formatSource, compressSource, documentMode.
Keyboard shortcuts
Mod is Ctrl on Windows/Linux, Cmd on macOS.
| Shortcut | Action |
|---|---|
| Mod-B / Mod-I / Mod-U | Bold / italic / underline |
| Mod-Shift-S | Strikethrough |
| Mod-. / Mod-, | Superscript / subscript |
| Mod-Shift-B | Blockquote |
| Mod-Alt-1 … Mod-Alt-3 | Heading level 1–3 |
| Mod-Shift-7 / Mod-Shift-8 | Ordered / bullet list |
| Mod-] / Mod-[ | Indent / outdent |
| Tab / Shift-Tab | Nest / lift the current list item (this is why Tab is deliberately not bound to indent) |
| Mod-Z / Mod-Shift-Z | Undo / redo |
Shortcuts belong to their feature: disable the feature and the shortcut goes with it. Undo/redo have no toolbar buttons.
Examples
Email template editor
import { useState } from 'react';
import { MomentumEditor } from '@momentum-amp/editor-react';
import '@momentum-amp/editor-core/styles.css';
const FEATURES = [
'headings', 'fonts', 'sizes',
'bold', 'italic', 'underline', 'strike', 'superscript', 'subscript', 'blockquote',
'align', 'color', 'highlight', 'lists', 'indent',
'link', 'image', 'divider', 'mergeTags', 'emoji', 'clean',
] as const;
export function TemplateEditor({ template, onChange }) {
return (
<MomentumEditor
value={template.body}
onChange={onChange}
features={[...FEATURES]}
allowCodeView
minHeight={320}
maxHeight={640}
placeholder="Compose your template…"
mergeTags={template.availableTags}
imageUpload={{ upload: uploadToCdn, maxSizeMB: 10 }}
showUploadErrors={false}
onImageUploadError={(e) => toast.error(e.message)}
theme={{
primaryColor: '#381574',
borderRadius: '12px',
controlRadius: '8px',
textColor: '#222222',
fontSize: '13px',
}}
/>
);
}Comment box — narrow toolbar, full schema
// features stays complete so nothing in stored content is destroyed;
// the toolbar is what gets narrowed.
<MomentumEditor
value={html}
onChange={setHtml}
toolbar={[['bold', 'italic', 'underline'], ['lists'], ['link']]}
minHeight={96}
maxHeight={240}
placeholder="Add a comment"
/>Read-only rendering
<MomentumEditor value={storedHtml} readOnly toolbar={false} autoGrow />Imperative insertion from outside the editor
const editorRef = useRef<MomentumEditorRef>(null);
const insertName = () => editorRef.current?.insertMergeTag('Contact.FirstName');
const loadTemplate = (html: string) => editorRef.current?.setHTML(html);
const warn = () => editorRef.current?.notify('Nothing to send yet', 'info');
<MomentumEditor ref={editorRef} defaultValue="" mergeTags={tags} />Next.js / SSR
Importing the component on the server is safe: it renders nothing until mount (immediatelyRender: false internally), so there is no hydration mismatch and no document access during SSR. No dynamic(..., { ssr: false }) wrapper is required.
HTML contract
What onChange / getHTML() guarantee, in short:
- inline styles only — never classes;
<p style="margin: 0">so mail-client defaults cannot add spacing;- lists carry
list-style-type,list-style-position: outside,padding-left,margin; - headings carry
font-size,font-weight,line-height,margin;blockquotecarries its border and padding;<hr>states all four border sides; - merge tags are literal
{Token}/{{Token}}text; - blank lines are
<p><br></p>; - indentation is
padding-left: (level × 3)em— the legacy step; <img>is self-closed with a hosted URL, never base64;- upload placeholders never appear;
getHTML(setHTML(x)) === xis a verified fixed point.
Input accepts this package's own output plus legacy markup from a prior editor. Full spec, including the two deliberate divergences: docs/guides/html-contract.md.
Requirements & dependencies
| | |
|---|---|
| Peer dependencies | react >= 17, react-dom >= 17 (CI builds and tests against React 18) |
| Dependencies | @momentum-amp/editor-core, @tiptap/react |
| Transitively | Tiptap v3 (@tiptap/core, @tiptap/starter-kit, @tiptap/pm and a handful of official extensions) |
| Node | ≥ 18 for building; the package itself is browser code |
| Formats | ESM + CJS + .d.ts. TypeScript types ship with the package |
| Runtime | Requires a DOM. In tests, use jsdom and shim the layout APIs ProseMirror needs |
If your app also uses Tiptap or ProseMirror directly, keep the versions aligned. ProseMirror requires a single instance of its core packages; two copies in one bundle produce confusing schema and selection errors.
Limitations & known considerations
- Omitting a feature destroys stored formatting. Not a bug — a schema consequence. Narrow the
toolbar, notfeatures. This is the single most common way to lose content with this package. - Base64 images in stored content are dropped on load. The schema sets
allowBase64: false. Some legacy editors accepted pasted base64, so pre-migration records can contain it; migrate those images to hosted URLs before opening such records for editing. - Document HTML disables rich editing. Layout tables, full documents and
<iframe>embeds route to verbatim passthrough; the code view is the only editing surface for them. - List items serialize as
<li><p style="margin: 0px;">text</p></li>— ProseMirror wraps list content in a paragraph. Mail clients render it fine, and the inner<p>adds no spacing, but the markup differs from the legacy<li>text</li>. - Runs of 2+ spaces in text collapse, as they did before. Attribute values are untouched.
- Author-set paragraph styles are dropped (
line-height,margin-bottom,text-indent);marginis replaced withmargin: 0. A legacy paragraph relying on deliberate bottom spacing collapses. - Links gain
target="_blank" rel="noopener noreferrer nofollow"from the Link extension. Mail clients ignore these; host views honour them. - Bare hrefs are rewritten on load —
www.example.combecomeshttp://www.example.com, because a protocol-less href resolves against the reader's origin and is a dead link in an email. Scriptable schemes lose the attribute and keep their text. readOnlyhides the toolbar entirely. There is no read-only-with-toolbar state.- Size with
minHeight/maxHeight/autoGrow, never a CSSheighton the root. A root height fights the content area's own floor and either clips or spills content. codeandcodeBlockexist in the schema (they come with Tiptap's StarterKit) but have no toolbar buttons and nofeaturesentry. The```input rule therefore works; if you never want code blocks, strip them from stored HTML on save.- No table editing, no footnotes, no collaborative editing, no track changes. Tables in stored content are preserved via passthrough, not editable as tables.
ref.tiptapis an unstable escape hatch. It bypasses the HTML contract; anything you build on it can break in a minor release.- Email-client rendering has not been certified. The output follows email-safe rules by construction and is used in production, but a formal Gmail / Outlook-MSO / Apple Mail / mobile rendering pass is still open QA work. Test your own templates against your own audience's clients.
Links
- Core package (framework-free):
@momentum-amp/editor-core - Repository & full docs: github.com/20-Miles-Repo/momentum-editor
- HTML contract: docs/guides/html-contract.md
- Issues: github.com/20-Miles-Repo/momentum-editor/issues
MIT © Momentum AMP
