dpk-editor
v0.1.1
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,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 };
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.
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-text-align(["heading","paragraph"])@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)
License
MIT
