From a48af6702b5b07cee1033e11094e5c51f4e337cb Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 16:40:01 +0530 Subject: [PATCH 01/18] feat(email-editor): writing-first editor with Write and Style modes and new blocks (#169) Write mode edits text paragraph by paragraph with markdown shortcuts, a format bar, a / block menu, {{ merge tags, undo/redo and a send checklist; Style mode previews desktop, mobile, sample data and dark inboxes beside the palette and block settings. Adds spacer, columns, social, html and conditional blocks, and a quickEdit hook so host blocks get the compact popover. Hosts can still add their own blocks and leave out any of ours. --- .changeset/richer-email-editor.md | 9 + bun.lock | 10 +- packages/email-blocks/src/email-preview.tsx | 2 + packages/email-editor/README.md | 12 + packages/email-editor/package.json | 2 + .../email-editor/src/blocks/columns/block.tsx | 63 + .../email-editor/src/blocks/columns/index.ts | 20 + .../src/blocks/columns/metadata.ts | 23 + .../src/blocks/columns/quick-edit.tsx | 24 + .../src/blocks/columns/settings.tsx | 82 + .../email-editor/src/blocks/columns/types.ts | 12 + .../src/blocks/conditional/block.tsx | 78 + .../src/blocks/conditional/index.ts | 21 + .../src/blocks/conditional/metadata.ts | 18 + .../src/blocks/conditional/quick-edit.tsx | 48 + .../src/blocks/conditional/settings.tsx | 52 + .../src/blocks/conditional/types.ts | 16 + .../email-editor/src/blocks/html/block.tsx | 50 + .../email-editor/src/blocks/html/index.ts | 17 + .../email-editor/src/blocks/html/metadata.ts | 14 + .../src/blocks/html/quick-edit.tsx | 21 + .../email-editor/src/blocks/html/settings.tsx | 26 + .../email-editor/src/blocks/html/types.ts | 5 + .../email-editor/src/blocks/image/index.ts | 3 + .../src/blocks/image/quick-edit.tsx | 48 + packages/email-editor/src/blocks/index.ts | 5 + .../email-editor/src/blocks/link/index.ts | 2 + .../src/blocks/link/quick-edit.tsx | 30 + .../email-editor/src/blocks/social/block.tsx | 70 + .../email-editor/src/blocks/social/index.ts | 18 + .../src/blocks/social/metadata.ts | 19 + .../src/blocks/social/quick-edit.tsx | 52 + .../src/blocks/social/settings.tsx | 93 + .../email-editor/src/blocks/social/types.ts | 21 + .../email-editor/src/blocks/spacer/block.tsx | 26 + .../email-editor/src/blocks/spacer/index.ts | 14 + .../src/blocks/spacer/metadata.ts | 16 + .../src/blocks/spacer/quick-edit.tsx | 34 + .../src/blocks/spacer/settings.tsx | 38 + .../email-editor/src/blocks/spacer/types.ts | 5 + .../email-editor/src/blocks/text/block.tsx | 107 +- .../src/components/add-block-button.tsx | 71 - .../src/components/block-menu.tsx | 118 + .../email-editor/src/components/block-row.tsx | 281 +++ .../src/components/block-settings-panel.tsx | 111 - .../src/components/block-wrapper.tsx | 213 -- .../email-editor/src/components/checklist.tsx | 129 ++ .../src/components/controls-bar.tsx | 98 + .../src/components/doc-header.tsx | 169 ++ .../src/components/email-editor.test.tsx | 274 +++ .../src/components/email-editor.tsx | 1367 ++++++++--- .../email-editor/src/components/inspector.tsx | 217 ++ .../src/components/layout/editor-layout.tsx | 27 - .../email-editor/src/components/text-item.tsx | 594 +++++ packages/email-editor/src/editor.css | 2004 +++++++++++++++++ packages/email-editor/src/email-editor.tsx | 38 +- packages/email-editor/src/index.ts | 1 + .../email-editor/src/lib/block-choices.ts | 132 ++ .../email-editor/src/lib/block-policy.test.ts | 37 + packages/email-editor/src/lib/block-policy.ts | 24 + .../email-editor/src/lib/checklist.test.ts | 169 ++ packages/email-editor/src/lib/checklist.ts | 270 +++ .../src/lib/email-renderer.test.tsx | 96 + .../email-editor/src/lib/email-renderer.tsx | 33 +- packages/email-editor/src/lib/history.test.ts | 42 + packages/email-editor/src/lib/history.ts | 57 + .../src/lib/inline-markdown.test.ts | 51 + .../email-editor/src/lib/inline-markdown.ts | 87 + .../email-editor/src/lib/markdown-content.tsx | 89 + .../src/lib/markdown-edit.test.ts | 94 + .../email-editor/src/lib/markdown-edit.ts | 169 ++ .../email-editor/src/lib/menu-placement.ts | 24 + packages/email-editor/src/lib/palettes.ts | 116 + .../email-editor/src/lib/text-items.test.ts | 151 ++ packages/email-editor/src/lib/text-items.ts | 220 ++ .../email-editor/src/types/block-registry.ts | 5 + packages/email-editor/src/types/editor-ui.ts | 25 + .../email-editor/src/types/email-editor.ts | 5 + 78 files changed, 8044 insertions(+), 790 deletions(-) create mode 100644 .changeset/richer-email-editor.md create mode 100644 packages/email-editor/src/blocks/columns/block.tsx create mode 100644 packages/email-editor/src/blocks/columns/index.ts create mode 100644 packages/email-editor/src/blocks/columns/metadata.ts create mode 100644 packages/email-editor/src/blocks/columns/quick-edit.tsx create mode 100644 packages/email-editor/src/blocks/columns/settings.tsx create mode 100644 packages/email-editor/src/blocks/columns/types.ts create mode 100644 packages/email-editor/src/blocks/conditional/block.tsx create mode 100644 packages/email-editor/src/blocks/conditional/index.ts create mode 100644 packages/email-editor/src/blocks/conditional/metadata.ts create mode 100644 packages/email-editor/src/blocks/conditional/quick-edit.tsx create mode 100644 packages/email-editor/src/blocks/conditional/settings.tsx create mode 100644 packages/email-editor/src/blocks/conditional/types.ts create mode 100644 packages/email-editor/src/blocks/html/block.tsx create mode 100644 packages/email-editor/src/blocks/html/index.ts create mode 100644 packages/email-editor/src/blocks/html/metadata.ts create mode 100644 packages/email-editor/src/blocks/html/quick-edit.tsx create mode 100644 packages/email-editor/src/blocks/html/settings.tsx create mode 100644 packages/email-editor/src/blocks/html/types.ts create mode 100644 packages/email-editor/src/blocks/image/quick-edit.tsx create mode 100644 packages/email-editor/src/blocks/link/quick-edit.tsx create mode 100644 packages/email-editor/src/blocks/social/block.tsx create mode 100644 packages/email-editor/src/blocks/social/index.ts create mode 100644 packages/email-editor/src/blocks/social/metadata.ts create mode 100644 packages/email-editor/src/blocks/social/quick-edit.tsx create mode 100644 packages/email-editor/src/blocks/social/settings.tsx create mode 100644 packages/email-editor/src/blocks/social/types.ts create mode 100644 packages/email-editor/src/blocks/spacer/block.tsx create mode 100644 packages/email-editor/src/blocks/spacer/index.ts create mode 100644 packages/email-editor/src/blocks/spacer/metadata.ts create mode 100644 packages/email-editor/src/blocks/spacer/quick-edit.tsx create mode 100644 packages/email-editor/src/blocks/spacer/settings.tsx create mode 100644 packages/email-editor/src/blocks/spacer/types.ts delete mode 100644 packages/email-editor/src/components/add-block-button.tsx create mode 100644 packages/email-editor/src/components/block-menu.tsx create mode 100644 packages/email-editor/src/components/block-row.tsx delete mode 100644 packages/email-editor/src/components/block-settings-panel.tsx delete mode 100644 packages/email-editor/src/components/block-wrapper.tsx create mode 100644 packages/email-editor/src/components/checklist.tsx create mode 100644 packages/email-editor/src/components/controls-bar.tsx create mode 100644 packages/email-editor/src/components/doc-header.tsx create mode 100644 packages/email-editor/src/components/email-editor.test.tsx create mode 100644 packages/email-editor/src/components/inspector.tsx delete mode 100644 packages/email-editor/src/components/layout/editor-layout.tsx create mode 100644 packages/email-editor/src/components/text-item.tsx create mode 100644 packages/email-editor/src/editor.css create mode 100644 packages/email-editor/src/lib/block-choices.ts create mode 100644 packages/email-editor/src/lib/checklist.test.ts create mode 100644 packages/email-editor/src/lib/checklist.ts create mode 100644 packages/email-editor/src/lib/history.test.ts create mode 100644 packages/email-editor/src/lib/history.ts create mode 100644 packages/email-editor/src/lib/inline-markdown.test.ts create mode 100644 packages/email-editor/src/lib/inline-markdown.ts create mode 100644 packages/email-editor/src/lib/markdown-content.tsx create mode 100644 packages/email-editor/src/lib/markdown-edit.test.ts create mode 100644 packages/email-editor/src/lib/markdown-edit.ts create mode 100644 packages/email-editor/src/lib/menu-placement.ts create mode 100644 packages/email-editor/src/lib/palettes.ts create mode 100644 packages/email-editor/src/lib/text-items.test.ts create mode 100644 packages/email-editor/src/lib/text-items.ts create mode 100644 packages/email-editor/src/types/editor-ui.ts diff --git a/.changeset/richer-email-editor.md b/.changeset/richer-email-editor.md new file mode 100644 index 00000000..991516f8 --- /dev/null +++ b/.changeset/richer-email-editor.md @@ -0,0 +1,9 @@ +--- +"@sendlit/email-editor": minor +"@sendlit/email-blocks": minor +"@sendlit/api-contract": minor +--- + +A redesigned email editor focused on writing. Write mode puts the subject and preview text above the email, edits text inline as markdown with a format bar, a `/` block menu and `{{` merge tags, and reorders blocks by dragging; Style mode previews desktop, mobile, sample data and a dark inbox beside a palette, typography and block panel. Text is written paragraph by paragraph: Enter starts a new paragraph or list item, Backspace joins paragraphs, markdown shortcuts (`#`, `##`, `-`, `1.`, `>`) turn a paragraph into a heading, list or quote, and merge tags show as chips; text blocks are still stored as one markdown block each. Blocks can define `quickEdit` for the compact popover shown while writing. Adds undo and redo, a "Before you send" checklist (with one-click fallbacks for merge tags), and `subject`, `onSubjectChange`, `variables`, `senderName`, `theme` and `defaultMode` props on `EmailEditor`. + +New blocks: `spacer`, `columns`, `social`, `html` and `conditional` (a section shown to contacts with or without a tag; marketing only). The API validates them on write, and rejects `conditional` blocks in transactional content with `conditional_not_allowed`. `renderEmailToHtml` takes `showConditionals` for previews. diff --git a/bun.lock b/bun.lock index 9ebd51c1..8a0cfebb 100644 --- a/bun.lock +++ b/bun.lock @@ -82,7 +82,7 @@ "fumadocs-openapi": "10.4.1", "fumadocs-ui": "16.7.1", "lucide-react": "^0.577.0", - "next": "16.3.6", + "next": "16.4.1", "react": "19.2.8", "react-dom": "19.2.8", "shiki": "^4.3.1", @@ -117,7 +117,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", - "next": "16.3.6", + "next": "16.4.1", "next-themes": "^0.4.6", "papaparse": "^5.7.0", "posthog-js": "^1.425.1", @@ -160,7 +160,7 @@ }, "packages/email-blocks": { "name": "@sendlit/email-blocks", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.18", "@radix-ui/react-avatar": "^1.2.1", @@ -219,6 +219,8 @@ "uuid": "^11.1.1", }, "devDependencies": { + "@happy-dom/global-registrator": "21.0.3", + "@testing-library/react": "^16.3.2", "@types/node": "^22.20.0", "@types/react": "^19.2.17", "autoprefixer": "^10.5.2", @@ -235,7 +237,7 @@ }, "packages/sendlit": { "name": "sendlit", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@ts-rest/core": "3.52.1", "zod": "3.25.76", diff --git a/packages/email-blocks/src/email-preview.tsx b/packages/email-blocks/src/email-preview.tsx index 9dc2ce90..234fcf12 100644 --- a/packages/email-blocks/src/email-preview.tsx +++ b/packages/email-blocks/src/email-preview.tsx @@ -70,6 +70,8 @@ export function EmailPreview({ email: normalizedEmail, blocks, renderContext, + // A preview has no subscriber, so show conditional sections as written. + showConditionals: true, }) .then((html) => { startTransition(() => { diff --git a/packages/email-editor/README.md b/packages/email-editor/README.md index 349a490b..07997a0f 100644 --- a/packages/email-editor/README.md +++ b/packages/email-editor/README.md @@ -54,3 +54,15 @@ export default App() { return () } ``` + +## Writing mode, subject and merge tags + +`EmailEditor` opens in Write mode (subject and preview text above the email, inline markdown text, a `/` block menu, `{{` merge tags, drag to reorder) and has a Style mode for the look and the selected block. Pass `subject` and `onSubjectChange` to edit the subject in the editor, `variables` for merge tags, `senderName` for the inbox preview and `theme` (`"sendlit"`, `"neutral"` or `"dark"`). + +The editor's look is a set of `--sle-*` CSS variables on the `.sle` element, so it can be re-skinned without touching the components. The styles are in `@sendlit/email-editor/styles.css`. + +Besides text, link, image and separator it ships `spacer`, `columns`, `social`, `html` and `conditional` blocks. A `conditional` block becomes a Liquid condition on `subscriber.tags` when rendered for sending; pass `showConditionals` to `renderEmailToHtml` to preview every section. + +## Custom blocks + +Pass `blocks` to choose the block types: add your own, and leave out any of the built-in ones. A block can provide `quickEdit` (and `quickEditLayout`) for the compact popover shown while writing; its full `settings` form appears in Style mode. Paragraph-by-paragraph writing applies to the built-in `text` block only, so a registry without it, or with a host block named `text`, gets ordinary selectable blocks. diff --git a/packages/email-editor/package.json b/packages/email-editor/package.json index b3022f51..7e4949b3 100644 --- a/packages/email-editor/package.json +++ b/packages/email-editor/package.json @@ -42,6 +42,8 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@happy-dom/global-registrator": "21.0.3", + "@testing-library/react": "^16.3.2", "@types/node": "^22.20.0", "@types/react": "^19.2.17", "autoprefixer": "^10.5.2", diff --git a/packages/email-editor/src/blocks/columns/block.tsx b/packages/email-editor/src/blocks/columns/block.tsx new file mode 100644 index 00000000..5f7f1555 --- /dev/null +++ b/packages/email-editor/src/blocks/columns/block.tsx @@ -0,0 +1,63 @@ +import { Column, Row, Section } from "@react-email/components"; +import { MarkdownContent } from "@/lib/markdown-content"; +import type { EmailBlock, EmailStyle } from "@/types/email-editor"; +import type { ColumnsBlockSettings, ColumnsRatio } from "./types"; + +const WIDTHS: Record = { + "11": ["50%", "50%"], + "12": ["33%", "67%"], + "21": ["67%", "33%"], +}; + +const STACK_CSS = + "@media only screen and (max-width:480px){.sl-col{display:block!important;width:100%!important;padding-left:0!important;padding-right:0!important}}"; + +export function ColumnsBlock({ + block, + style, +}: { + block: EmailBlock & { settings: ColumnsBlockSettings }; + style?: EmailStyle; +}) { + const { + left = "", + right = "", + ratio = "11", + stack = true, + alignment = "left", + backgroundColor = "transparent", + foregroundColor = style?.colors.foreground, + paddingTop = style?.structure.section.padding?.y, + paddingBottom = style?.structure.section.padding?.y, + } = block.settings as ColumnsBlockSettings & { foregroundColor?: string }; + const [leftWidth, rightWidth] = WIDTHS[ratio] ?? WIDTHS["11"]; + const gap = "12px"; + + return ( +
+ {stack && } +
+ + + + + + + + +
+
+ ); +} diff --git a/packages/email-editor/src/blocks/columns/index.ts b/packages/email-editor/src/blocks/columns/index.ts new file mode 100644 index 00000000..d527b946 --- /dev/null +++ b/packages/email-editor/src/blocks/columns/index.ts @@ -0,0 +1,20 @@ +import type { BlockComponent } from "@/types/block-registry"; +import { ColumnsBlock as block } from "./block"; +import { metadata } from "./metadata"; +import { ColumnsSettings as settings } from "./settings"; +import { ColumnsQuickEdit as quickEdit } from "./quick-edit"; +import type { ColumnsBlockSettings } from "./types"; + +export const ColumnsBlock: BlockComponent = { + block, + settings, + metadata, + quickEdit, + quickEditLayout: "column", + defaultSettings: () => ({ + left: "**Left column**\n\nA short note.", + right: "**Right column**\n\nAnother note.", + ratio: "11", + stack: true, + }), +}; diff --git a/packages/email-editor/src/blocks/columns/metadata.ts b/packages/email-editor/src/blocks/columns/metadata.ts new file mode 100644 index 00000000..1b34c6c5 --- /dev/null +++ b/packages/email-editor/src/blocks/columns/metadata.ts @@ -0,0 +1,23 @@ +import { Columns2 } from "lucide-react"; +import type { BlockMetadata } from "@/types/block-registry"; + +export const metadata: BlockMetadata = { + name: "columns", + displayName: "Columns", + description: "Two text columns side by side", + icon: Columns2, + docs: { + settings: { + left: "The markdown content of the left column.", + right: "The markdown content of the right column.", + ratio: + "[Optional] The column widths, left to right. Range: 11 (equal), 12 (narrow then wide), 21 (wide then narrow). Default: 11", + stack: + "[Optional] Stack the columns on narrow screens. Range: true, false. Default: true", + backgroundColor: + "[Optional] The background color of the block. Format: #000000. Default: transparent", + paddingTop: "[Optional] The top padding. Format: 0px. Range: 0-100", + paddingBottom: "[Optional] The bottom padding. Format: 0px. Range: 0-100", + }, + }, +}; diff --git a/packages/email-editor/src/blocks/columns/quick-edit.tsx b/packages/email-editor/src/blocks/columns/quick-edit.tsx new file mode 100644 index 00000000..d9497cce --- /dev/null +++ b/packages/email-editor/src/blocks/columns/quick-edit.tsx @@ -0,0 +1,24 @@ +import type { BlockSettingsProps } from "@/types/email-editor"; +import type { ColumnsBlockSettings } from "./types"; + +export function ColumnsQuickEdit({ + block, + updateBlock, +}: BlockSettingsProps) { + const set = (key: "left" | "right", value: string) => + updateBlock(block.id, { settings: { ...block.settings, [key]: value } }); + + return ( +
+ {(["left", "right"] as const).map((side) => ( +