react-email-studio 3.8.1 → 3.8.3

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/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.8.3] - 2026-05-26
9
+
10
+ ### Fixed
11
+
12
+ - **Context menu Move Up / Move Down** now works for all block types: root `blocks[]` sections (row reorder), stacked blocks in a `rows[]` column (in-column swap), nested layout columns, and single-block layout rows.
13
+ - **Context menu clicks** no longer dismiss the menu before the action runs (`pointerdown` handling on the menu overlay).
14
+
15
+ ## [3.8.2] - 2026-05-22
16
+
17
+ ### Added
18
+
19
+ - Added **`RELEASE.md`** as a single-file release tutorial and included it in the published npm package.
20
+
21
+ ### Fixed
22
+
23
+ - **Text block newline handling** is now consistent in canvas preview and exported HTML: plain text line breaks render as `<br/>`, while HTML content remains passthrough.
24
+
8
25
  ## [3.8.0] - 2026-05-15
9
26
 
10
27
  ### Changed
package/README.md CHANGED
@@ -7,7 +7,9 @@
7
7
 
8
8
  ## Release notes
9
9
 
10
- **Latest: 3.8.0** — export uses **`rows[]`** + **`_reactEmailStudio`** (LMS shape); palette blocks add as root sections; section reorder via drag handle or inspector.
10
+ **Latest: 3.8.3** — fixes context menu **Move Up / Move Down** for root sections, stacked column blocks, and nested layout blocks.
11
+
12
+ See **3.8.2** for the **`RELEASE.md`** npm tutorial and plain-text newline rendering fixes.
11
13
 
12
14
  Version history and migration hints: **[CHANGELOG.md](./CHANGELOG.md)** (also included in the published npm tarball under `node_modules/react-email-studio/CHANGELOG.md`).
13
15
 
@@ -121,7 +123,8 @@ Implement **`onUpload`** so image/video uploads return URLs your recipients can
121
123
  | `ReactEmailEditorOptions`, `JsonToHtmlOptions`, `EmailHtmlOptions` | Editor options and HTML generation options. |
122
124
  | `EmailDocument`, `EmailDocumentSettings`, … | JSON schema types for stored designs. |
123
125
 
124
- For framework setup (Next.js client/SSR), props tables, and troubleshooting, see **[TUTORIAL.md](./TUTORIAL.md)**.
126
+ For a single-file release tutorial, see **[RELEASE.md](./RELEASE.md)**.
127
+ For extended framework setup (Next.js client/SSR), props tables, and troubleshooting, see **[TUTORIAL.md](./TUTORIAL.md)**.
125
128
 
126
129
  ---
127
130
 
package/RELEASE.md ADDED
@@ -0,0 +1,137 @@
1
+ # react-email-studio release tutorial (single file)
2
+
3
+ Use this file as the one-stop guide for npm integration, save/load, HTML export, and API usage.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install react-email-studio
9
+ ```
10
+
11
+ Install peer dependencies in your app:
12
+
13
+ - `react`, `react-dom`
14
+ - `lucide-react`
15
+ - TipTap v3:
16
+ - `@tiptap/react`
17
+ - `@tiptap/core`
18
+ - `@tiptap/starter-kit`
19
+ - `@tiptap/extension-link`
20
+ - `@tiptap/extension-placeholder`
21
+ - `@tiptap/extension-text-align`
22
+ - `@tiptap/extension-text-style`
23
+ - `@tiptap/extension-underline`
24
+
25
+ ## Minimal integration
26
+
27
+ ```tsx
28
+ import { useRef, useCallback } from "react";
29
+ import {
30
+ ReactEmailEditor,
31
+ type ReactEmailEditorRef,
32
+ jsonToHtml,
33
+ } from "react-email-studio";
34
+
35
+ export function MailStudioPage() {
36
+ const ref = useRef<ReactEmailEditorRef>(null);
37
+
38
+ const save = useCallback(() => {
39
+ ref.current?.exportJson((jsonString) => {
40
+ void fetch("/api/email-designs", {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify({ design: jsonString }),
44
+ });
45
+ }, true);
46
+ }, []);
47
+
48
+ return (
49
+ <>
50
+ <button type="button" onClick={save}>Save design</button>
51
+ <ReactEmailEditor
52
+ ref={ref}
53
+ hideTemplates
54
+ onUpload={uploadImageAndReturnUrl}
55
+ onReady={(api) => {
56
+ // api.loadJson(savedDesignJsonOrObject);
57
+ }}
58
+ />
59
+ </>
60
+ );
61
+ }
62
+
63
+ async function uploadImageAndReturnUrl(file: File): Promise<string> {
64
+ const body = new FormData();
65
+ body.append("file", file);
66
+ const res = await fetch("/api/upload", { method: "POST", body });
67
+ const { url } = await res.json();
68
+ return url as string;
69
+ }
70
+
71
+ export function htmlFromStoredDesign(designJson: string): string {
72
+ return jsonToHtml(designJson);
73
+ }
74
+ ```
75
+
76
+ Important: `onUpload` must return public HTTPS URLs that recipients can access.
77
+
78
+ ## Next.js note
79
+
80
+ The editor is client-side. Use:
81
+
82
+ ```tsx
83
+ "use client";
84
+ ```
85
+
86
+ If needed:
87
+
88
+ ```tsx
89
+ import dynamic from "next/dynamic";
90
+ const ReactEmailEditor = dynamic(
91
+ () => import("react-email-studio").then((m) => m.ReactEmailEditor),
92
+ { ssr: false },
93
+ );
94
+ ```
95
+
96
+ ## Core API
97
+
98
+ - `ref.loadJson(input)`:
99
+ - Accepts JSON string or object.
100
+ - Replaces editor content.
101
+ - `ref.exportJson(cb, pretty?)`:
102
+ - Returns canonical `email_document` JSON.
103
+ - `jsonToHtml(design, opts?)`:
104
+ - Converts design JSON to full HTML document.
105
+ - `htmlToJson(html, pretty?)`:
106
+ - Converts pasted HTML to `email_document` JSON.
107
+
108
+ ## Exported helpers
109
+
110
+ - `htmlToEmailDesignTemplate`
111
+ - `extractHtmlForDesign`
112
+ - `canonicalizeEmailDocument`
113
+ - `utf8ToBase64`, `base64ToUtf8`
114
+ - `EmailPreviewModal`, `emailPreviewDevices`
115
+
116
+ ## JSON storage shape
117
+
118
+ Persist `exportJson` output as your source of truth:
119
+
120
+ - `type: "email_document"`
121
+ - `settings`
122
+ - `rows[]` (preferred export shape)
123
+ - row `layout`, `styles`, `columns[].blocks[]`
124
+ - `_reactEmailStudio` metadata for editor round-trip
125
+
126
+ Import supports legacy `blocks[]` and older styles, but export writes `rows[]`.
127
+
128
+ ## Troubleshooting
129
+
130
+ - Missing module errors: install all peers with compatible versions.
131
+ - Blank HTML from `jsonToHtml`: invalid/empty design input.
132
+ - Broken email images: `onUpload` URL is private, local, or expired.
133
+ - SSR errors: ensure client boundary (`"use client"` or dynamic `ssr: false`).
134
+
135
+ ## License
136
+
137
+ MIT
package/dist/index.cjs CHANGED
@@ -864,6 +864,65 @@ function countBlocksInDesign(rows) {
864
864
  }, 0);
865
865
  return rows.reduce((sum, r) => sum + r.cells.reduce((s, c) => s + colBlocks(c), 0), 0);
866
866
  }
867
+ function isSingleBlockInRow(row) {
868
+ if (!row?.cells) return false;
869
+ let count = 0;
870
+ for (const col of row.cells) {
871
+ if (!Array.isArray(col)) continue;
872
+ count += col.length;
873
+ if (count > 1) return false;
874
+ }
875
+ return count === 1;
876
+ }
877
+ function resolveBlockMovePlan(rows, rowId, cellIdx, blockIndex, inner) {
878
+ const rowIndex = rows.findIndex((r) => r.id === rowId);
879
+ const row = rowIndex >= 0 ? rows[rowIndex] : void 0;
880
+ if (!row) return null;
881
+ if (inner) {
882
+ const loc = toColumnLoc(rowId, cellIdx, {
883
+ parentBlockIdx: inner.parentBlockIdx,
884
+ innerCellIdx: inner.cellIdx
885
+ });
886
+ const col2 = getColumnBlocks(rows, loc);
887
+ const idx = inner.contentIdx;
888
+ return {
889
+ mode: "column",
890
+ blockIndex: idx,
891
+ canMoveUp: idx > 0,
892
+ canMoveDown: idx < col2.length - 1
893
+ };
894
+ }
895
+ const col = row.cells?.[cellIdx];
896
+ const colLen = Array.isArray(col) ? col.length : 0;
897
+ if (colLen > 1) {
898
+ return {
899
+ mode: "column",
900
+ blockIndex,
901
+ canMoveUp: blockIndex > 0,
902
+ canMoveDown: blockIndex < colLen - 1
903
+ };
904
+ }
905
+ if (colLen === 1 && (isRootContentRow(row) || isSingleBlockInRow(row))) {
906
+ return {
907
+ mode: "row",
908
+ canMoveUp: rowIndex > 0,
909
+ canMoveDown: rowIndex < rows.length - 1
910
+ };
911
+ }
912
+ if (colLen === 1) {
913
+ return {
914
+ mode: "row",
915
+ canMoveUp: rowIndex > 0,
916
+ canMoveDown: rowIndex < rows.length - 1
917
+ };
918
+ }
919
+ return {
920
+ mode: "column",
921
+ blockIndex,
922
+ canMoveUp: false,
923
+ canMoveDown: false
924
+ };
925
+ }
867
926
 
868
927
  // src/lib/htmlUtils.ts
869
928
  function escHtmlAttr(s) {
@@ -872,6 +931,11 @@ function escHtmlAttr(s) {
872
931
  function escHtml(s) {
873
932
  return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
874
933
  }
934
+ function textBlockContentToHtml(content) {
935
+ const raw = typeof content === "string" ? content : "";
936
+ if (/^\s*<[^>]+>/.test(raw)) return raw;
937
+ return escHtml(raw).replace(/\r?\n/g, "<br/>");
938
+ }
875
939
  function utf8ToBase64(raw) {
876
940
  const bytes = new TextEncoder().encode(raw);
877
941
  let bin = "";
@@ -1212,8 +1276,7 @@ function blockToHtml(cb) {
1212
1276
  case "text": {
1213
1277
  const shell = emailSurfaceBgCss(p);
1214
1278
  const inner = `font-size:${lenPx(p.fontSize)};color:${p.color};text-align:${p.align};font-weight:${p.fontWeight || 400};font-style:${p.italic ? "italic" : "normal"};text-decoration:${p.underline ? "underline" : "none"};line-height:${lh(p.lineHeight)};letter-spacing:${lenPx(p.letterSpacing)};font-family:${p.fontFamily || "Georgia,serif"}`;
1215
- const content = typeof p.content === "string" ? p.content : "";
1216
- const body = /^\s*<[^>]+>/.test(content) ? content : escHtml(content).replace(/\r?\n/g, "<br/>");
1279
+ const body = textBlockContentToHtml(p.content);
1217
1280
  return `<div style="${pd(p.padding)};${marginCss()}${shell}"><div style="${inner}">${body}</div></div>`;
1218
1281
  }
1219
1282
  case "html": {
@@ -3052,7 +3115,7 @@ function ContentBlock({ block, selected, onClick, preview, C }) {
3052
3115
  "div",
3053
3116
  {
3054
3117
  style: { fontSize: p.fontSize, color: p.color, textAlign: p.align, fontWeight: p.fontWeight || (p.bold ? 700 : 400), fontStyle: p.italic ? "italic" : "normal", textDecoration: p.underline ? "underline" : "none", lineHeight: p.lineHeight || 1.65, letterSpacing: `${p.letterSpacing || 0}px`, fontFamily: p.fontFamily || "Georgia,serif" },
3055
- dangerouslySetInnerHTML: { __html: p.content }
3118
+ dangerouslySetInnerHTML: { __html: textBlockContentToHtml(p.content) }
3056
3119
  }
3057
3120
  )
3058
3121
  );
@@ -7424,50 +7487,64 @@ var import_jsx_runtime8 = require("react/jsx-runtime");
7424
7487
  function ContextMenu({ x, y, items, onClose, C }) {
7425
7488
  const ref = (0, import_react6.useRef)(null);
7426
7489
  (0, import_react6.useEffect)(() => {
7427
- const h = () => onClose();
7428
- window.addEventListener("mousedown", h);
7429
- window.addEventListener("keydown", h);
7490
+ const onPointerDown = (ev) => {
7491
+ if (ref.current?.contains(ev.target)) return;
7492
+ onClose();
7493
+ };
7494
+ const onKeyDown = (ev) => {
7495
+ if (ev.key === "Escape") onClose();
7496
+ };
7497
+ window.addEventListener("pointerdown", onPointerDown);
7498
+ window.addEventListener("keydown", onKeyDown);
7430
7499
  return () => {
7431
- window.removeEventListener("mousedown", h);
7432
- window.removeEventListener("keydown", h);
7500
+ window.removeEventListener("pointerdown", onPointerDown);
7501
+ window.removeEventListener("keydown", onKeyDown);
7433
7502
  };
7434
7503
  }, [onClose]);
7435
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { ref, onMouseDown: (e) => e.stopPropagation(), style: { position: "fixed", left: x, top: y, zIndex: 9999, background: C.sidebar, border: `1px solid ${C.border}`, borderRadius: 7, padding: "4px 0", boxShadow: `0 8px 32px rgba(0,0,0,.4)`, minWidth: 160 }, children: items.map((item, i) => item.sep ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { style: { height: 1, background: C.border, margin: "3px 0" } }, i) : /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
7436
- "button",
7504
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
7505
+ "div",
7437
7506
  {
7438
- type: "button",
7439
- disabled: !!item.disabled,
7440
- onClick: () => {
7441
- if (item.disabled) return;
7442
- item.action();
7443
- onClose();
7444
- },
7445
- style: {
7446
- display: "block",
7447
- width: "100%",
7448
- background: "none",
7449
- border: "none",
7450
- padding: "7px 14px",
7451
- textAlign: "left",
7452
- cursor: item.disabled ? "not-allowed" : "pointer",
7453
- fontSize: 12,
7454
- color: item.disabled ? C.muted : item.danger ? C.danger : C.text,
7455
- fontWeight: item.bold ? 700 : 400,
7456
- opacity: item.disabled ? 0.45 : 1
7457
- },
7458
- onMouseEnter: (e) => {
7459
- if (!item.disabled) e.currentTarget.style.background = C.surface;
7460
- },
7461
- onMouseLeave: (e) => {
7462
- e.currentTarget.style.background = "none";
7463
- },
7464
- children: [
7465
- item.icon && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { style: { marginRight: 8 }, children: item.icon }),
7466
- item.label
7467
- ]
7468
- },
7469
- i
7470
- )) });
7507
+ ref,
7508
+ onPointerDown: (e) => e.stopPropagation(),
7509
+ style: { position: "fixed", left: x, top: y, zIndex: 9999, background: C.sidebar, border: `1px solid ${C.border}`, borderRadius: 7, padding: "4px 0", boxShadow: `0 8px 32px rgba(0,0,0,.4)`, minWidth: 160 },
7510
+ children: items.map((item, i) => item.sep ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { style: { height: 1, background: C.border, margin: "3px 0" } }, i) : /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
7511
+ "button",
7512
+ {
7513
+ type: "button",
7514
+ disabled: !!item.disabled,
7515
+ onClick: () => {
7516
+ if (item.disabled) return;
7517
+ item.action();
7518
+ onClose();
7519
+ },
7520
+ style: {
7521
+ display: "block",
7522
+ width: "100%",
7523
+ background: "none",
7524
+ border: "none",
7525
+ padding: "7px 14px",
7526
+ textAlign: "left",
7527
+ cursor: item.disabled ? "not-allowed" : "pointer",
7528
+ fontSize: 12,
7529
+ color: item.disabled ? C.muted : item.danger ? C.danger : C.text,
7530
+ fontWeight: item.bold ? 700 : 400,
7531
+ opacity: item.disabled ? 0.45 : 1
7532
+ },
7533
+ onMouseEnter: (e) => {
7534
+ if (!item.disabled) e.currentTarget.style.background = C.surface;
7535
+ },
7536
+ onMouseLeave: (e) => {
7537
+ e.currentTarget.style.background = "none";
7538
+ },
7539
+ children: [
7540
+ item.icon && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { style: { marginRight: 8 }, children: item.icon }),
7541
+ item.label
7542
+ ]
7543
+ },
7544
+ i
7545
+ ))
7546
+ }
7547
+ );
7471
7548
  }
7472
7549
 
7473
7550
  // src/editor/overlays/ConfirmDialog.tsx
@@ -8071,10 +8148,27 @@ var ReactEmailEditorComponent = (0, import_react8.forwardRef)(
8071
8148
  else dropContent(rowId, cellIdx, { kind: "duplicate", fromIdx: contentIdx });
8072
8149
  }
8073
8150
  };
8151
+ const shiftBlockFromMenu = (rowId, cellIdx, ci, dir, inner = null) => {
8152
+ const blockIndex = inner ? inner.contentIdx : ci;
8153
+ const plan = resolveBlockMovePlan(rowsRef.current, rowId, cellIdx, blockIndex, inner);
8154
+ if (!plan) return;
8155
+ if (dir < 0 && !plan.canMoveUp || dir > 0 && !plan.canMoveDown) return;
8156
+ if (plan.mode === "row") {
8157
+ moveRow(rowId, dir);
8158
+ return;
8159
+ }
8160
+ moveContent(rowId, cellIdx, plan.blockIndex, dir, inner);
8161
+ };
8074
8162
  const handleContextMenu = (e, rowId, cellIdx, ci, inner = null) => {
8163
+ e.preventDefault();
8164
+ e.stopPropagation();
8165
+ const blockIndex = inner ? inner.contentIdx : ci;
8166
+ const plan = resolveBlockMovePlan(rowsRef.current, rowId, cellIdx, blockIndex, inner);
8167
+ const canMoveUp = plan?.canMoveUp ?? false;
8168
+ const canMoveDown = plan?.canMoveDown ?? false;
8075
8169
  setCtxMenu({ x: e.clientX, y: e.clientY, items: [
8076
- { icon: "\u2191", label: "Move Up", action: () => moveContent(rowId, cellIdx, ci, -1, inner) },
8077
- { icon: "\u2193", label: "Move Down", action: () => moveContent(rowId, cellIdx, ci, 1, inner) },
8170
+ { icon: "\u2191", label: "Move Up", disabled: !canMoveUp, action: () => shiftBlockFromMenu(rowId, cellIdx, ci, -1, inner) },
8171
+ { icon: "\u2193", label: "Move Down", disabled: !canMoveDown, action: () => shiftBlockFromMenu(rowId, cellIdx, ci, 1, inner) },
8078
8172
  { sep: true },
8079
8173
  { icon: "\u29C9", label: "Duplicate", action: () => dropContent(rowId, cellIdx, inner ? { kind: "duplicate", fromIdx: inner.contentIdx, fromNested: { parentBlockIdx: inner.parentBlockIdx, innerCellIdx: inner.cellIdx } } : { kind: "duplicate", fromIdx: ci }) },
8080
8174
  { sep: true },
@@ -8084,17 +8178,14 @@ var ReactEmailEditorComponent = (0, import_react8.forwardRef)(
8084
8178
  const handleLayoutContextMenu = (e, rowId) => {
8085
8179
  e.preventDefault();
8086
8180
  e.stopPropagation();
8087
- const ri = rows.findIndex((r) => r.id === rowId);
8181
+ const currentRows = rowsRef.current;
8182
+ const ri = currentRows.findIndex((r) => r.id === rowId);
8088
8183
  setSelectedRowId(rowId);
8089
8184
  setSelContentId(null);
8090
8185
  setSelMeta(null);
8091
8186
  setCtxMenu({ x: e.clientX, y: e.clientY, items: [
8092
- { icon: "\u2191", label: "Move row up", disabled: ri <= 0, action: () => {
8093
- if (ri > 0) moveRow(rowId, -1);
8094
- } },
8095
- { icon: "\u2193", label: "Move row down", disabled: ri < 0 || ri >= rows.length - 1, action: () => {
8096
- if (ri >= 0 && ri < rows.length - 1) moveRow(rowId, 1);
8097
- } },
8187
+ { icon: "\u2191", label: "Move row up", disabled: ri <= 0, action: () => moveRow(rowId, -1) },
8188
+ { icon: "\u2193", label: "Move row down", disabled: ri < 0 || ri >= currentRows.length - 1, action: () => moveRow(rowId, 1) },
8098
8189
  { sep: true },
8099
8190
  { icon: "\u29C9", label: "Duplicate row", action: () => dupRow(rowId) },
8100
8191
  { sep: true },