dpk-editor
v0.1.4
Published
A React feature-rich HTML rich-text (WYSIWYG) editor built on TipTap v3. Use it anywhere — CMS bodies, comments, documents, marketing pages, or email. Preserves inline styles and the full document shell, and produces clean, portable, paste-anywhere markup
Maintainers
Readme
dpk-editor
A React feature-rich HTML rich-text editor built on TipTap v3. Use it anywhere you need a WYSIWYG editor that produces clean, portable, inline-styled HTML — CMS bodies, comment boxes, document editors, marketing pages, or email composers.
Unlike editors that emit class-based or framework-coupled markup, this one is designed around three constraints that keep the output portable and paste-anywhere safe:
- Full documents are supported —
<!doctype html><html><body style="…">…. AcontentEditable/ProseMirror surface cannot host<html>/<head>/<body>; it silently strips them. So the editor edits only the body fragment and preserves the surrounding document shell verbatim (the document-shell bridge). Pass it a full document or a bare fragment — it round-trips either. - Inline styles are preserved —
style="padding:…;background:…"on buttons, colored headings, table layouts. TipTap strips thestyleattribute by default. The bundledPreserveStylesextension keeps it, so styled content survives the round-trip. - Output is portable, bulletproof markup — call-to-action buttons are
<a style="display:inline-block;…">wrapped in an aligned<p>, not<button>— markup that renders consistently across browsers, CMSes, and even email clients.
- ✅ Ships ESM + CJS + type declarations
- ✅ Works in Next.js App Router and plain Vite/CRA (
immediatelyRender:falseset internally for SSR) - ✅ No Tailwind/shadcn requirement — plain CSS you import
- ✅ TypeScript strict, no
anyin public types
Install
npm i dpk-editor
# peer deps (you already have these in a React app):
npm i react react-domimport { EmailEditor } from "dpk-editor";
import "dpk-editor/styles.css"; // requiredThe main component is exported as
EmailEditor(and as the default export) for historical reasons — it is a fully general HTML editor and works for any content, not just email.
Usage
<EmailEditor> — batteries included (default export)
The full editor: toolbar, editable surface, document-shell bridge, optional insertable token chips, and image-upload wiring. value may be a full <!doctype><html><body>… document or a bare fragment; onChange always emits in the same shape it received.
import { useState } from "react";
import { EmailEditor } from "dpk-editor";
import "dpk-editor/styles.css";
export default function Composer() {
const [html, setHtml] = useState("<h1>Hello, world</h1><p>Start writing…</p>");
return (
<EmailEditor
value={html}
onChange={setHtml}
// Optional: clickable chips that insert any text/token at the caret.
placeholders={[
{ token: "{{FirstName}}", label: "First name" },
{ token: "{{Date}}", label: "Today's date" },
]}
onUploadImage={async (file) => {
// upload `file` somewhere and return a hosted URL
return "https://cdn.example.com/x.png";
}}
minHeight={288}
/>
);
}<RichTextEditor> — the generic body-HTML editor (named export)
The toolbar + editable surface operating on a plain HTML fragment (no <html>/<body>). EmailEditor is a thin wrapper that adds the shell bridge + token chips around this. Use it if you want to build your own wrapper or only ever deal with a body fragment.
import { useRef, useState } from "react";
import { RichTextEditor, type RichTextEditorHandle } from "dpk-editor";
import "dpk-editor/styles.css";
function Body() {
const [body, setBody] = useState("<p>Body HTML only.</p>");
const ref = useRef<RichTextEditorHandle>(null);
return (
<>
<button onClick={() => ref.current?.insertAtCaret("{{FirstName}}")}>
Insert token
</button>
<RichTextEditor
ref={ref}
value={body}
onChange={setBody}
editorStyle={{ minHeight: 240 }}
toolbar={{ button: false }} // hide the button-builder control
/>
</>
);
}Props
EmailEditorProps
| Prop | Type | Default | Description |
| --------------- | ----------------------------------------- | -------------- | ----------- |
| value | string | — | Full HTML document or bare body fragment. |
| onChange | (value: string) => void | — | Fires with HTML in the same shape as value (shell re-applied). |
| placeholders | EmailPlaceholder[] | undefined | Insertable tokens → renders a chip row that inserts each at the caret. |
| onUploadImage | (file: File) => Promise<string> | undefined | Resolve an upload to a URL. If omitted, the image button prompts for a URL. |
| minHeight | number | 288 | Min height (px) of the editable surface. |
| placeholder | string | "Write something…" | Empty-state text. |
| toolbar | ToolbarConfig | all on | Which controls to render. |
| className | string | undefined | Extra class on the wrapper. |
RichTextEditorProps
| Prop | Type | Default | Description |
| --------------- | ----------------------------------- | ------- | ----------- |
| value | string | — | Body-level HTML fragment. |
| onChange | (value: string) => void | — | Fires with the body-level HTML fragment. |
| placeholder | string | "Write something…" | Empty-state text. |
| onUploadImage | (file: File) => Promise<string> | undefined | Upload handler (else URL prompt). |
| className | string | undefined | Extra class on the wrapper. |
| editorStyle | React.CSSProperties | undefined | Applied to the editable surface (e.g. { minHeight }). |
| toolbar | ToolbarConfig | all on | Which controls to render. |
RichTextEditor is a forwardRef exposing RichTextEditorHandle:
type RichTextEditorHandle = { insertAtCaret: (htmlOrText: string) => void };ToolbarConfig
Every control defaults to on. There are two levels of control:
- Single-control groups (
link,image,button,html) are plain booleans — set tofalseto hide. - Multi-button groups (
inline,headings,lists,align,table,blocks) accept either a boolean (whole group on/off) or an object of per-button booleans for fine-grained control. Unlisted buttons stay on, so you only list what you want to change.
type ToolbarConfig = {
inline?: boolean | {
bold?: boolean; italic?: boolean; underline?: boolean;
strike?: boolean; code?: boolean;
};
headings?: boolean | {
h2?: boolean; h3?: boolean; h4?: boolean; h5?: boolean; h6?: boolean;
};
lists?: boolean | { bullet?: boolean; ordered?: boolean; blockquote?: boolean };
align?: boolean | { left?: boolean; center?: boolean; right?: boolean };
table?: boolean | {
insert?: boolean; addRow?: boolean; deleteRow?: boolean;
addColumn?: boolean; deleteColumn?: boolean; headerRow?: boolean;
mergeCells?: boolean; deleteTable?: boolean;
};
blocks?: boolean | { paragraph?: boolean; divider?: boolean; footer?: boolean };
link?: boolean; // Link control
image?: boolean; // Image control
button?: boolean; // Button-builder dialog
html?: boolean; // Raw HTML source toggle
};Examples:
// Hide whole groups:
<EmailEditor toolbar={{ button: false, html: false, blocks: false }} … />
// Keep the inline group but drop just Underline and Inline-code:
<EmailEditor toolbar={{ inline: { underline: false, code: false } }} … />
// Only offer H2 and H3 headings:
<EmailEditor toolbar={{ headings: { h4: false, h5: false, h6: false } }} … />
// Mix both levels freely:
<EmailEditor
toolbar={{
inline: { code: false }, // group on, hide one button
align: false, // whole group off
image: false, // single control off
}}
…
/>
trueand{}are equivalent (group on, all buttons on).falsehides the whole group. Hidden groups leave no dangling toolbar dividers.
For advanced use, resolveToolbarConfig(config) (exported) returns the flat,
fully-expanded ResolvedToolbarConfig the toolbar renders from.
Building blocks (also exported)
For advanced use, the underlying pieces are exported:
import {
PreserveStyles, // the TipTap extension that keeps inline `style`
createEmailExtensions, // the full extension set as a factory
buildButtonHtml, // (config: EmailButtonConfig) => string
EmailButtonDialog, // the button-config modal
splitEmailHtml, // (html) => { prefix, body, suffix }
joinEmailHtml, // (shell, body) => html
extractEmailBody, // (html) => body fragment
escapeHtml,
presets, // { paragraph, divider, footer, heading }
} from "dpk-editor";
import type {
EmailButtonConfig,
EmailPlaceholder,
ToolbarConfig,
EmailHtmlDocument,
} from "dpk-editor";buildButtonHtml
Builds portable, inline-styled HTML for a clickable button (an <a>, so it works in any HTML context — including email clients that won't render a <button>):
const html = buildButtonHtml({
text: "Get started",
href: "https://example.com",
bgColor: "#2563eb",
textColor: "#ffffff",
align: "center",
radius: 6,
fullWidth: false,
});
// → <p style="margin:16px 0;text-align:center"><a href="…" style="display:inline-block;…">Get started</a></p>The anchor is wrapped in a <p> (not a <div>) on purpose: TipTap has no div node and would unwrap a <div>, losing the alignment. A paragraph's text-align is preserved by the TextAlign extension.
Image alignment
Images are alignable: select one and use the toolbar's align left/center/right buttons, exactly as for a heading or paragraph. The alignment is written onto the image as style="text-align: …" and round-trips through value/onChange, alongside any inline style the image already carries.
<img src="https://cdn.example.com/logo.png" style="text-align: center; border-radius: 8px">
styles.csscarries the rule that makes this visible.text-alignaligns a block's inline contents, so on an<img>it does nothing by itself.styles.csstranslates the keyword into auto margins (.rte-content img[style*="text-align: center"] { display:block; margin-left:auto; margin-right:auto }). Without the stylesheet the align buttons appear to do nothing to an image.
If you render the output somewhere that does not load styles.css (an email client, another site's page), copy the same three rules into that context — or post-process the <img> tags into align/margin attributes for the target.
Tables
Tables are first-class: insert one from the toolbar, add or delete rows and columns, toggle the header row, merge and split cells, and drag a column border to resize it. Everything but Insert table is disabled until the caret is inside a table, so a control that would do nothing says so before it is pressed.
<table>, <tr>, <th>, <td>, colspan and rowspan round-trip through
value/onChange, as do inline styles inside the cells.
This is why
TableKitis not optional. ProseMirror parses incoming HTML against the schema and drops any element the schema has no node for — so an editor without the table nodes does not merely refuse to make tables, it destroys the ones it is given: every<table>you load or paste comes back as a run of bare paragraphs, one per cell, and the host saves that flattening the moment the writer touches anything. If you build your own extension set withcreateEmailExtensionsas a guide, keepTableKitin it.
Two things a host should know about the markup that comes out:
- Column widths are written twice. Resizing puts a
colwidthattribute on the cells — that is the one the editor reads back, so it is what makes a width survive being loaded again — and the table renders it out a second time as<colgroup><col style="width: …">, which is what a page rendering the stored HTML lays out from. If you sanitise before storing, allow both (colwidthonth/td, andcolgroup/colwith awidth/min-widthstyle) or a resized column springs back the next time the table is opened. - There is no
<thead>. A header row is<th>cells in the first<tr>, inside<tbody>like every other row. A<thead>you load is read correctly and comes back in that shape.
styles.css carries the cell borders and the rules column resizing needs —
position: relative on the cells, table-layout: fixed on the table, and the
.column-resize-handle / .tableWrapper / .selectedCell rules. Without the
stylesheet, resizing and the merge selection stop working rather than merely
looking plain.
Theming
styles.css targets .rte-* classes and the .ProseMirror/.rte-content surface, and exposes CSS custom properties with neutral defaults. Override any of them on a parent element (or :root):
.my-editor {
--rte-accent: #7c3aed;
--rte-accent-contrast: #ffffff;
--rte-border: #e2e8f0;
--rte-border-strong: #cbd5e1;
--rte-bg: #ffffff;
--rte-bg-muted: #f8fafc;
--rte-fg: #0f172a;
--rte-fg-muted: #64748b;
--rte-radius: 10px;
--rte-active-bg: #f5f3ff;
}<EmailEditor className="my-editor" value={html} onChange={setHtml} />SSR / Next.js App Router
- The component entry is marked
"use client", so importing it from a Server Component is fine — render<EmailEditor>inside a client boundary. immediatelyRender: falseis set internally, which prevents the hydration mismatch TipTap otherwise throws under SSR. You don't need to configure anything.
Built-in extensions
The editor is configured with TipTap v3:
StarterKit(which already bundles Link, Underline, lists, blockquote, code, and headings — do not add@tiptap/extension-linkor@tiptap/extension-underlineyourself), with Link configuredopenOnClick:false,autolink:true,rel="noopener noreferrer".@tiptap/extension-image(inline:false,allowBase64:false)@tiptap/extension-tableasTableKit(table: { resizable: true }) — the table, row, header-cell and cell nodes. See Tables; leaving it out silently flattens every table the editor is given.@tiptap/extension-text-align(["heading","paragraph","image"]— see Image alignment)@tiptap/extension-text-style(TextStyle+Color, re-exported here — no separate@tiptap/extension-colorneeded)@tiptap/extension-highlight(multicolor:true)@tiptap/extension-placeholder(StarterKit v3 does not bundle it; added explicitly for the empty-state hint, which sets theis-editor-empty/is-emptyclass anddata-placeholderattribute thatstyles.cssrenders)PreserveStyles(this package). It preserves thestyleattribute verbatim, with one deliberate exception: on the node types TextAlign manages (heading,paragraph,image) it yields ownership of thetext-aligndeclaration to TextAlign. Both extensions writestyleon the same node, and without that hand-off the copy captured at parse time would out-live the live alignment — re-aligning would be ignored and un-aligning would never clear. Every other declaration, andtext-alignon every other type (list items, blockquotes, marks), is preserved untouched.
License
MIT
