dpk-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 <AUTHOR>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,269 @@
1
+ # dpk-editor
2
+
3
+ A React rich-text editor built on **TipTap v3**, purpose-built for composing **HTML emails**.
4
+
5
+ Editing email HTML is not like editing article HTML. This package is designed around three constraints that ordinary rich-text editors get wrong:
6
+
7
+ 1. **Emails are full documents** — `<!doctype html><html><body style="…"><table>…`. A `contentEditable`/ProseMirror surface cannot host `<html>`/`<head>`/`<body>`; it silently strips them. So this editor edits only the **body fragment** and preserves the surrounding document shell verbatim (the *document-shell bridge*).
8
+ 2. **Emails rely on inline styles** — `style="padding:…;background:…"` on buttons, colored headings, table layouts. TipTap strips the `style` attribute by default. The bundled `PreserveStyles` extension keeps it.
9
+ 3. **Email clients need bulletproof markup** — CTA buttons are `<a style="display:inline-block;…">` wrapped in an aligned `<p>`, not `<button>`.
10
+
11
+ - ✅ Ships **ESM + CJS + type declarations**
12
+ - ✅ Works in **Next.js App Router** and plain **Vite/CRA** (`immediatelyRender:false` set internally for SSR)
13
+ - ✅ No Tailwind/shadcn requirement — plain CSS you import
14
+ - ✅ TypeScript strict, no `any` in public types
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm i dpk-editor
20
+ # peer deps (you already have these in a React app):
21
+ npm i react react-dom
22
+ ```
23
+
24
+ ```ts
25
+ import { EmailEditor } from "dpk-editor";
26
+ import "dpk-editor/styles.css"; // required
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ### `<EmailEditor>` — batteries included (default export)
32
+
33
+ Handles the document-shell bridge, placeholder 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.
34
+
35
+ ```tsx
36
+ import { useState } from "react";
37
+ import { EmailEditor } from "dpk-editor";
38
+ import "dpk-editor/styles.css";
39
+
40
+ export default function Composer() {
41
+ const [html, setHtml] = useState(
42
+ "<html><body><h1>Hi {{FirstName}}</h1></body></html>",
43
+ );
44
+
45
+ return (
46
+ <EmailEditor
47
+ value={html}
48
+ onChange={setHtml}
49
+ placeholders={[
50
+ { token: "{{FirstName}}", label: "First name" },
51
+ { token: "{{Email}}", label: "Email" },
52
+ ]}
53
+ onUploadImage={async (file) => {
54
+ // upload `file` somewhere and return a hosted URL
55
+ return "https://cdn.example.com/x.png";
56
+ }}
57
+ minHeight={288}
58
+ />
59
+ );
60
+ }
61
+ ```
62
+
63
+ ### `<RichTextEditor>` — the generic body-HTML editor (named export)
64
+
65
+ The toolbar + editable surface operating on a **plain HTML fragment** (no `<html>`/`<body>`). `EmailEditor` is a thin wrapper that adds the shell bridge + chips around this. Use it if you want to build your own wrapper.
66
+
67
+ ```tsx
68
+ import { useRef, useState } from "react";
69
+ import { RichTextEditor, type RichTextEditorHandle } from "dpk-editor";
70
+ import "dpk-editor/styles.css";
71
+
72
+ function Body() {
73
+ const [body, setBody] = useState("<p>Body HTML only.</p>");
74
+ const ref = useRef<RichTextEditorHandle>(null);
75
+
76
+ return (
77
+ <>
78
+ <button onClick={() => ref.current?.insertAtCaret("{{FirstName}}")}>
79
+ Insert token
80
+ </button>
81
+ <RichTextEditor
82
+ ref={ref}
83
+ value={body}
84
+ onChange={setBody}
85
+ editorStyle={{ minHeight: 240 }}
86
+ toolbar={{ button: false }} // hide the CTA-button control
87
+ />
88
+ </>
89
+ );
90
+ }
91
+ ```
92
+
93
+ ## Props
94
+
95
+ ### `EmailEditorProps`
96
+
97
+ | Prop | Type | Default | Description |
98
+ | --------------- | ----------------------------------------- | -------------- | ----------- |
99
+ | `value` | `string` | — | Full HTML document **or** bare body fragment. |
100
+ | `onChange` | `(value: string) => void` | — | Fires with HTML in the same shape as `value` (shell re-applied). |
101
+ | `placeholders` | `EmailPlaceholder[]` | `undefined` | Merge tokens → renders a chip row that inserts at the caret. |
102
+ | `onUploadImage` | `(file: File) => Promise<string>` | `undefined` | Resolve an upload to a URL. If omitted, the image button prompts for a URL. |
103
+ | `minHeight` | `number` | `288` | Min height (px) of the editable surface. |
104
+ | `placeholder` | `string` | `"Write your email…"` | Empty-state text. |
105
+ | `toolbar` | `ToolbarConfig` | all on | Which controls to render. |
106
+ | `className` | `string` | `undefined` | Extra class on the wrapper. |
107
+
108
+ ### `RichTextEditorProps`
109
+
110
+ | Prop | Type | Default | Description |
111
+ | --------------- | ----------------------------------- | ------- | ----------- |
112
+ | `value` | `string` | — | Body-level HTML fragment. |
113
+ | `onChange` | `(value: string) => void` | — | Fires with the body-level HTML fragment. |
114
+ | `placeholder` | `string` | `"Write your email…"` | Empty-state text. |
115
+ | `onUploadImage` | `(file: File) => Promise<string>` | `undefined` | Upload handler (else URL prompt). |
116
+ | `className` | `string` | `undefined` | Extra class on the wrapper. |
117
+ | `editorStyle` | `React.CSSProperties` | `undefined` | Applied to the editable surface (e.g. `{ minHeight }`). |
118
+ | `toolbar` | `ToolbarConfig` | all on | Which controls to render. |
119
+
120
+ `RichTextEditor` is a `forwardRef` exposing `RichTextEditorHandle`:
121
+
122
+ ```ts
123
+ type RichTextEditorHandle = { insertAtCaret: (htmlOrText: string) => void };
124
+ ```
125
+
126
+ ### `ToolbarConfig`
127
+
128
+ Every control defaults to **on**. There are two levels of control:
129
+
130
+ - **Single-control groups** (`link`, `image`, `button`, `html`) are plain
131
+ booleans — set to `false` to hide.
132
+ - **Multi-button groups** (`inline`, `headings`, `lists`, `align`, `blocks`)
133
+ accept **either** a boolean (whole group on/off) **or** an object of
134
+ per-button booleans for fine-grained control. Unlisted buttons stay on, so
135
+ you only list what you want to change.
136
+
137
+ ```ts
138
+ type ToolbarConfig = {
139
+ inline?: boolean | {
140
+ bold?: boolean; italic?: boolean; underline?: boolean;
141
+ strike?: boolean; code?: boolean;
142
+ };
143
+ headings?: boolean | {
144
+ h2?: boolean; h3?: boolean; h4?: boolean; h5?: boolean; h6?: boolean;
145
+ };
146
+ lists?: boolean | { bullet?: boolean; ordered?: boolean; blockquote?: boolean };
147
+ align?: boolean | { left?: boolean; center?: boolean; right?: boolean };
148
+ blocks?: boolean | { paragraph?: boolean; divider?: boolean; footer?: boolean };
149
+ link?: boolean; // Link control
150
+ image?: boolean; // Image control
151
+ button?: boolean; // Email CTA button dialog
152
+ html?: boolean; // Raw HTML source toggle
153
+ };
154
+ ```
155
+
156
+ Examples:
157
+
158
+ ```tsx
159
+ // Hide whole groups:
160
+ <EmailEditor toolbar={{ button: false, html: false, blocks: false }} … />
161
+
162
+ // Keep the inline group but drop just Underline and Inline-code:
163
+ <EmailEditor toolbar={{ inline: { underline: false, code: false } }} … />
164
+
165
+ // Only offer H2 and H3 headings:
166
+ <EmailEditor toolbar={{ headings: { h4: false, h5: false, h6: false } }} … />
167
+
168
+ // Mix both levels freely:
169
+ <EmailEditor
170
+ toolbar={{
171
+ inline: { code: false }, // group on, hide one button
172
+ align: false, // whole group off
173
+ image: false, // single control off
174
+ }}
175
+
176
+ />
177
+ ```
178
+
179
+ > `true` and `{}` are equivalent (group on, all buttons on). `false` hides the
180
+ > whole group. Hidden groups leave no dangling toolbar dividers.
181
+
182
+ For advanced use, `resolveToolbarConfig(config)` (exported) returns the flat,
183
+ fully-expanded `ResolvedToolbarConfig` the toolbar renders from.
184
+
185
+ ## Building blocks (also exported)
186
+
187
+ For advanced use, the underlying pieces are exported:
188
+
189
+ ```ts
190
+ import {
191
+ PreserveStyles, // the TipTap extension that keeps inline `style`
192
+ createEmailExtensions, // the full extension set as a factory
193
+ buildButtonHtml, // (config: EmailButtonConfig) => string
194
+ EmailButtonDialog, // the button-config modal
195
+ splitEmailHtml, // (html) => { prefix, body, suffix }
196
+ joinEmailHtml, // (shell, body) => html
197
+ extractEmailBody, // (html) => body fragment
198
+ escapeHtml,
199
+ presets, // { paragraph, divider, footer, heading }
200
+ } from "dpk-editor";
201
+
202
+ import type {
203
+ EmailButtonConfig,
204
+ EmailPlaceholder,
205
+ ToolbarConfig,
206
+ EmailHtmlDocument,
207
+ } from "dpk-editor";
208
+ ```
209
+
210
+ ### `buildButtonHtml`
211
+
212
+ ```ts
213
+ const html = buildButtonHtml({
214
+ text: "Get started",
215
+ href: "https://example.com",
216
+ bgColor: "#2563eb",
217
+ textColor: "#ffffff",
218
+ align: "center",
219
+ radius: 6,
220
+ fullWidth: false,
221
+ });
222
+ // → <p style="margin:16px 0;text-align:center"><a href="…" style="display:inline-block;…">Get started</a></p>
223
+ ```
224
+
225
+ 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.
226
+
227
+ ## Theming
228
+
229
+ `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`):
230
+
231
+ ```css
232
+ .my-editor {
233
+ --rte-accent: #7c3aed;
234
+ --rte-accent-contrast: #ffffff;
235
+ --rte-border: #e2e8f0;
236
+ --rte-border-strong: #cbd5e1;
237
+ --rte-bg: #ffffff;
238
+ --rte-bg-muted: #f8fafc;
239
+ --rte-fg: #0f172a;
240
+ --rte-fg-muted: #64748b;
241
+ --rte-radius: 10px;
242
+ --rte-active-bg: #f5f3ff;
243
+ }
244
+ ```
245
+
246
+ ```tsx
247
+ <EmailEditor className="my-editor" value={html} onChange={setHtml} />
248
+ ```
249
+
250
+ ## SSR / Next.js App Router
251
+
252
+ - The component entry is marked `"use client"`, so importing it from a Server Component is fine — render `<EmailEditor>` inside a client boundary.
253
+ - `immediatelyRender: false` is set **internally**, which prevents the hydration mismatch TipTap otherwise throws under SSR. You don't need to configure anything.
254
+
255
+ ## Built-in extensions
256
+
257
+ The editor is configured with TipTap v3:
258
+
259
+ - `StarterKit` (which **already bundles** Link, Underline, lists, blockquote, code, and headings — do not add `@tiptap/extension-link` or `@tiptap/extension-underline` yourself), with Link configured `openOnClick:false`, `autolink:true`, `rel="noopener noreferrer"`.
260
+ - `@tiptap/extension-image` (`inline:false`, `allowBase64:false`)
261
+ - `@tiptap/extension-text-align` (`["heading","paragraph"]`)
262
+ - `@tiptap/extension-text-style` (`TextStyle` + `Color`, re-exported here — no separate `@tiptap/extension-color` needed)
263
+ - `@tiptap/extension-highlight` (`multicolor:true`)
264
+ - `@tiptap/extension-placeholder` (StarterKit v3 does **not** bundle it; added explicitly for the empty-state hint, which sets the `is-editor-empty`/`is-empty` class and `data-placeholder` attribute that `styles.css` renders)
265
+ - `PreserveStyles` (this package)
266
+
267
+ ## License
268
+
269
+ MIT