mail-editor-pancake 0.0.9 → 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.
Files changed (42) hide show
  1. package/README.md +461 -303
  2. package/package.json +2 -2
  3. package/packages/blocks/dist/index.js +153 -150
  4. package/packages/blocks/dist/index.js.map +1 -1
  5. package/packages/blocks/src/html.ts +11 -3
  6. package/packages/blocks/src/text.ts +8 -3
  7. package/packages/core/dist/index.d.ts +220 -10
  8. package/packages/core/dist/index.js +3963 -2250
  9. package/packages/core/dist/index.js.map +1 -1
  10. package/packages/core/dist/style.css +1 -1
  11. package/packages/core/package.json +1 -1
  12. package/packages/core/src/editor/BlockCodeModal.ts +84 -2
  13. package/packages/core/src/editor/Canvas.ts +182 -26
  14. package/packages/core/src/editor/Editor.ts +414 -68
  15. package/packages/core/src/editor/ExportModal.ts +1 -1
  16. package/packages/core/src/editor/ImportDocModal.ts +129 -0
  17. package/packages/core/src/editor/InlineEditor.ts +238 -24
  18. package/packages/core/src/editor/LeftPanel.ts +55 -14
  19. package/packages/core/src/editor/Modal.ts +20 -1
  20. package/packages/core/src/editor/PreviewModal.ts +2 -2
  21. package/packages/core/src/editor/RichTextToolbar.ts +125 -18
  22. package/packages/core/src/editor/RightPanel.ts +165 -7
  23. package/packages/core/src/editor/Topbar.ts +306 -132
  24. package/packages/core/src/editor/VariablePickerPanel.ts +145 -0
  25. package/packages/core/src/editor/styles.css +410 -52
  26. package/packages/core/src/index.ts +32 -2
  27. package/packages/core/src/renderer/index.ts +9 -1
  28. package/packages/core/src/renderer/mjml.ts +4 -5
  29. package/packages/core/src/store/store.ts +13 -2
  30. package/packages/core/src/types.ts +65 -2
  31. package/packages/core/src/utils/docClipboard.ts +89 -0
  32. package/packages/core/src/utils/dynamicVariantHtml.ts +45 -0
  33. package/packages/core/src/utils/dynamicVariantKey.ts +24 -0
  34. package/packages/core/src/utils/dynamicVariantSection.ts +98 -0
  35. package/packages/core/src/utils/emailListStyles.ts +188 -0
  36. package/packages/core/src/utils/inlineListEditing.ts +774 -0
  37. package/packages/core/src/utils/lockedMjml.ts +146 -12
  38. package/packages/core/src/utils/modalSize.ts +27 -0
  39. package/packages/core/src/utils/richHtmlEmpty.ts +43 -0
  40. package/packages/core/src/utils/sectionLayout.ts +2 -2
  41. package/packages/core/src/variables/index.ts +3 -4
  42. package/playground/vanilla/src/main.ts +26 -1
package/README.md CHANGED
@@ -1,57 +1,204 @@
1
1
  # Simple Mail Editor
2
2
 
3
- 面向运营的轻量邮件可视化编辑器。聚焦邮件场景,用**严格扁平化的组件树** + **MJML 输出引擎**,
4
- 解决 GrapesJS 在邮件场景下"任意嵌套 → 拖拽混乱 → 文本组件消失"的痛点。
3
+ 面向运营的轻量邮件可视化编辑器(产品名 **Simple Mail**)。聚焦邮件场景,用**严格扁平化的组件树** + **MJML 输出引擎**,解决 GrapesJS 在邮件场景下「任意嵌套 → 拖拽混乱 → 文本组件消失」的痛点。
5
4
 
6
5
  ```text
7
- Doc → Section → Column → Block // 仅四层,不允许 Block 再含子组件
6
+ Doc → Section → Column → Block // 仅四层,Block 不可再嵌套子组件
8
7
  左栏组件区 │ 中间画布区 │ 右栏属性配置区
9
8
  ```
10
9
 
10
+ ## 目录
11
+
12
+ - [命名与 npm 包](#命名与-npm-包)
13
+ - [特性](#特性)
14
+ - [仓库结构](#仓库结构)
15
+ - [开发与构建](#开发与构建)
16
+ - [安装与集成](#安装与集成)
17
+ - [快速开始](#快速开始)
18
+ - [MailEditor API 速览](#maileditor-api-速览)
19
+ - [画布清空与重置](#画布清空与重置)
20
+ - [界面主题与品牌色](#界面主题与品牌色)
21
+ - [UI 选项 `ui`](#ui-选项-ui)
22
+ - [构造选项(画布行为)](#构造选项画布行为)
23
+ - [变量系统](#变量系统)
24
+ - [动态变量节(dynamicVariant)](#动态变量节dynamicvariant)
25
+ - [设计稿剪贴板](#设计稿剪贴板)
26
+ - [图片资源 `imageAssets`](#图片资源-imageassets)
27
+ - [框架集成示例](#框架集成示例)
28
+ - [自定义组件](#自定义组件)
29
+ - [宿主集成注意](#宿主集成注意)
30
+ - [操作手册](#操作手册)
31
+ - [设计与代码模式](#设计与代码模式)
32
+ - [数据模型速览](#数据模型速览)
33
+ - [路线图](#路线图)
34
+ - [设计取舍](#设计取舍)
35
+ - [许可证](#许可证)
36
+
37
+ ---
38
+
39
+ ## 命名与 npm 包
40
+
41
+ 三层名称各司其职,**不必强行统一成一个字符串**:
42
+
43
+ | 层级 | 名称 | 说明 |
44
+ |------|------|------|
45
+ | 仓库目录 | `simple-mail` | Git 路径、内部日志前缀(`[simple-mail]`)、剪贴板 kind(`simple-mail/doc`)、引擎标识等 |
46
+ | 对外子包 | `@simple-mail/core`、`@simple-mail/blocks` | **业务代码应使用的 import 名**;发布到 npm 的主包 |
47
+ | Monorepo 根 | `mail-editor-pancake` | 无 scope 的 `simple-mail` 在 npm 已被占用时的根包名;适合 `pnpm link` 整仓,**不作为对外 API 文档的安装名** |
48
+
49
+ ```text
50
+ simple-mail/ # 仓库根(package.json name: mail-editor-pancake)
51
+ ├── packages/
52
+ │ ├── core/ → @simple-mail/core
53
+ │ └── blocks/ → @simple-mail/blocks
54
+ └── playground/
55
+ └── vanilla/ → @simple-mail/playground-vanilla(本地演示,不发布)
56
+ ```
57
+
58
+ **推荐写法(npm 或 link 后统一用 scope):**
59
+
60
+ ```ts
61
+ import { MailEditor } from '@simple-mail/core';
62
+ import '@simple-mail/core/style.css';
63
+ import { allBlocks } from '@simple-mail/blocks';
64
+ ```
65
+
66
+ 不推荐长期依赖 `mail-editor-pancake/packages/core` 深路径;若宿主已 link 根包,请在 Vite alias 中把 `@simple-mail/*` 指到 `node_modules/mail-editor-pancake/packages/*`(见 [安装与集成](#安装与集成))。
67
+
68
+ ---
69
+
11
70
  ## 特性
12
71
 
13
72
  - 框架无关核心(vanilla TS),可用于 React / Vue / 原生项目
14
- - 严格两级 SortableJS 拖拽:Section 排序 + Column 内 Block 排序,**只能从 hover 出现的 ⋮⋮ 拖拽图标拖动**,避免点选文本时误拖
15
- - **画布内联富文本编辑**:双击 Text/Button 直接编辑;选中文字浮动工具条提供加粗/斜体/下划线/删除线、字体、字号、颜色、左/中/右对齐、有序/无序列表、链接、清除格式
16
- - **HTML 安全清洗**:富文本输入会被白名单清洗(标签 + 属性),`<font>` → `<span style>`,确保进入 MJML 的内容是邮件兼容的 inline-style HTML
17
- - 兼容性输出:内部维护 JSON Schema,导出时编译为 MJML,再由 MJML 编译为 Outlook/Gmail 可用 HTML
18
- - 双模式:设计态 + 源码态(文档级只读 MJML/HTML,组件级可编辑 MJML 锁定)
19
- - 内置组件:文本、图片、按钮、分隔线、间距,以及 1/2/3 列布局
20
- - 自定义组件示例:公司 Logo、业务社交链接(X / Rabbit / Facebook / TikTok / Instagram)、页脚
21
- - **图片字段**:`type: 'image'` 支持手输 URL;可选 **右侧「上传」**(`uploadImage`)与 **内置图库弹层**(`imageGallery` + `showGallery: true`);仍支持完全自管的 `pickImageFromGallery`
22
- - 撤销/重做、键盘删除、复制 Section/Block
23
- - **界面主题**:顶栏 **太阳 / 月亮 / 显示器** 图标切换浅色、深色、跟随系统(`prefers-color-scheme`);中间邮件画布仍为白纸以贴近成品
24
- - **品牌色**:可选 `accentColor` / `setAccentColor`,覆盖强调色与选区色;可选顶栏拾色器
25
- - **仅搭正文**:`ui.hideMailMeta` 隐藏主题 / Preheader 与顶栏「邮件设置」,保留版式宽度与全局样式
26
- - **画布清空 / 重置**:顶栏「清空画布」「重置内容」;`presetDoc` `initialDoc` 分离,编辑已保存邮件时重置仍回到业务预置模板
27
- - **变量系统**:`setVariables` 注入占位符列表;顶栏 `{{ }}` 弹层支持插 key / 插元素 / 复制;`kind: 'link' | 'image'` 区分链接片段与图片块
28
- - **点空白取消选中**:可选 `clearSelectionOnCanvasMargin`,点击画布灰色衬底或白底留白时提交内联编辑并清空选中
29
- - 包体可控:核心 + MJML + CodeMirror + 富文本,gzip ≈ 586KB
73
+ - 严格两级 SortableJS 拖拽:Section 排序 + Column 内 Block 排序;**仅从 hover 出现的 ⋮⋮ 图标拖动**,避免点选文本时误拖
74
+ - **画布内联编辑**:双击 Text/Button 等;浮动工具条支持加粗/斜体/下划线/删除线、字体、字号、颜色、对齐、列表、链接、清除格式
75
+ - **HTML 白名单清洗**:富文本进入 MJML 前清洗标签与属性;`<font>` → `<span style>`
76
+ - **MJML 管线**:内部 `EmailDoc` JSON MJML 邮件客户端可用 HTML
77
+ - 双模式:设计态 + 源码态(文档级只读 MJML/HTML;组件级可锁定 `lockedMjml`)
78
+ - 内置块:文本、HTML(raw)、图片、按钮、分隔线、间距、Hero、社交组,以及 1/2/3 列布局;示例自定义:Logo、单链社交、页脚
79
+ - **图片字段**:手输 URL;可选上传(`uploadImage`)、内置图库(`imageGallery`)、自管图床(`pickImageFromGallery`)
80
+ - 撤销/重做、键盘删除、复制 Section/Block;**复制/导入设计稿**(JSON 信封)
81
+ - **界面主题**:浅色 / 深色 / 跟随系统;画布仍为白纸贴近成品
82
+ - **品牌色**:`accentColor` / `setAccentColor`;可选顶栏拾色器
83
+ - **仅搭正文**:`ui.hideMailMeta` 隐藏主题、Preheader 与顶栏「邮件设置」
84
+ - **清空 / 重置**:`presetDoc` `initialDoc` 分离
85
+ - **变量系统**:`setVariables`;`kind: link | image`;顶栏 `{{ }}` 弹层
86
+ - **动态变量节**(可选):`Section.attrs.dynamicVariantKey`,导出时整节替换为 `{{key}}`,由宿主填充(券包等)
87
+ - **点空白取消选中**:`clearSelectionOnCanvasMargin`
88
+ - 包体:核心 + MJML + CodeMirror + 富文本,gzip ≈ 580–590KB(主要来自 `mjml-browser`)
89
+
90
+ ---
30
91
 
31
92
  ## 仓库结构
32
93
 
33
94
  ```text
34
95
  simple-mail/
35
96
  ├─ packages/
36
- │ ├─ core/ @simple-mail/core 核心引擎(store / 渲染 / 三栏 UI / 拖拽 / 代码模式)
37
- │ └─ blocks/ @simple-mail/blocks 内置组件 + 自定义示例
38
- └─ playground/
39
- └─ vanilla/ 原生 TS 演示
97
+ │ ├─ core/ @simple-mail/core store / 渲染 / 三栏 UI / 拖拽 / 代码模式
98
+ │ └─ blocks/ @simple-mail/blocks 内置块 + 示例自定义块
99
+ ├─ playground/
100
+ └─ vanilla/ 原生 TS 演示(pnpm dev)
101
+ ├─ package.json name: mail-editor-pancake(monorepo 根)
102
+ └─ pnpm-workspace.yaml
40
103
  ```
41
104
 
42
- ## 快速开始
105
+ ---
106
+
107
+ ## 开发与构建
108
+
109
+ 环境:**Node ≥ 20**,**pnpm ≥ 10**(见根 `packageManager`)。
43
110
 
44
111
  ```bash
112
+ cd simple-mail
45
113
  pnpm install
46
- pnpm dev # 启动 vanilla playground,默认 http://localhost:5173
47
- pnpm build # 构建 packages/core、packages/blocks(产出 dist/index.js、dist/style.css、dist/index.d.ts 等)
48
- pnpm typecheck # 全包类型检查
49
- pnpm --filter @simple-mail/playground-vanilla build # 产出静态站点
114
+ pnpm dev # playground,默认 http://localhost:5173
115
+ pnpm build # 构建 packages/core、packages/blocks dist/
116
+ pnpm typecheck # 全包类型检查
117
+ pnpm lint # biome check
118
+ pnpm --filter @simple-mail/playground-vanilla build
50
119
  ```
51
120
 
52
- ## 在你的项目里使用
121
+ 修改 `packages/core` 源码后,在 monorepo 根执行 **`pnpm build`**(或 `pnpm --filter @simple-mail/core build`),再让宿主重新加载,避免 link 场景下 `dist` 与源码不一致。
53
122
 
54
- ### Vanilla / 任意框架
123
+ ---
124
+
125
+ ## 安装与集成
126
+
127
+ ### 从 npm 安装(推荐)
128
+
129
+ ```bash
130
+ pnpm add @simple-mail/core @simple-mail/blocks
131
+ # 与 core 同版本传递依赖,宿主需能解析到:
132
+ pnpm add codemirror mjml-browser sortablejs
133
+ ```
134
+
135
+ ```ts
136
+ import { MailEditor } from '@simple-mail/core';
137
+ import '@simple-mail/core/style.css';
138
+ import { builtinBlocks, allBlocks } from '@simple-mail/blocks';
139
+ ```
140
+
141
+ - 仅需内置块时用 `builtinBlocks`;需要 Logo/页脚等示例时用 `allBlocks`,或与业务 `defineBlock` 合并。
142
+ - `@simple-mail/blocks` 依赖 `@simple-mail/core`,版本宜对齐。
143
+
144
+ ### 本地 link 整仓(monorepo 开发)
145
+
146
+ 宿主 `package.json`:
147
+
148
+ ```json
149
+ {
150
+ "dependencies": {
151
+ "mail-editor-pancake": "link:../simple-mail",
152
+ "@codemirror/lang-html": "^6.4.9",
153
+ "@codemirror/state": "^6.5.2",
154
+ "@codemirror/view": "^6.36.2",
155
+ "codemirror": "^6.0.1",
156
+ "mjml-browser": "^4.15.3",
157
+ "sortablejs": "^1.15.6"
158
+ }
159
+ }
160
+ ```
161
+
162
+ **业务 import 仍使用 `@simple-mail/*`**,在 Vite 中 alias 到 link 后的子包路径,并排除预构建缓存:
163
+
164
+ ```ts
165
+ // vite.config.ts 示意
166
+ const mailEditorPkgRoot = path.resolve(__dirname, 'node_modules/mail-editor-pancake/packages');
167
+
168
+ export default defineConfig({
169
+ resolve: {
170
+ alias: {
171
+ '@simple-mail/core': path.join(mailEditorPkgRoot, 'core'),
172
+ '@simple-mail/core/style.css': path.join(mailEditorPkgRoot, 'core/dist/style.css'),
173
+ '@simple-mail/blocks': path.join(mailEditorPkgRoot, 'blocks'),
174
+ // link 时 dist 内嵌套解析 codemirror / mjml-browser / sortablejs,常需指向宿主 node_modules
175
+ codemirror: path.dirname(require.resolve('codemirror/package.json')),
176
+ 'mjml-browser': path.dirname(require.resolve('mjml-browser/package.json')),
177
+ sortablejs: path.dirname(require.resolve('sortablejs/package.json')),
178
+ },
179
+ },
180
+ optimizeDeps: {
181
+ exclude: ['@simple-mail/core', '@simple-mail/blocks'],
182
+ },
183
+ });
184
+ ```
185
+
186
+ 仍出现「拖拽/组合拖入与源码不符」时:先 `pnpm build`(编辑器仓),再删除宿主 `node_modules/.vite` 并重启 dev。
187
+
188
+ ### 发布
189
+
190
+ 当前可发布子包:
191
+
192
+ | 包名 | 说明 |
193
+ |------|------|
194
+ | `@simple-mail/core` | 引擎 + 样式 `style.css` |
195
+ | `@simple-mail/blocks` | 内置与示例块定义 |
196
+
197
+ 根包 `mail-editor-pancake` 用于占位与 link,**文档与对外示例以 `@simple-mail/*` 为准**。发布前请在各子包目录执行 build,并保证 `files` 含 `dist`。
198
+
199
+ ---
200
+
201
+ ## 快速开始
55
202
 
56
203
  ```ts
57
204
  import { MailEditor } from '@simple-mail/core';
@@ -61,186 +208,168 @@ import { allBlocks } from '@simple-mail/blocks';
61
208
  const editor = new MailEditor({
62
209
  container: document.getElementById('app')!,
63
210
  blocks: allBlocks,
64
- /**
65
- * initialDoc 为 Partial<EmailDoc>:可预置 meta / styles / variables / sections(画布结构)。
66
- * 未写的字段会与默认空邮件合并;仅搭正文且由宿主管发件主题时可将 subject、preheader 留空,并配合 ui.hideMailMeta。
67
- */
68
211
  initialDoc: {
69
212
  meta: { subject: '欢迎', width: 600 },
70
213
  variables: [{ key: 'user.name', label: '用户名', sample: '张三' }],
71
214
  sections: [],
72
215
  },
73
- /**
74
- * 顶栏「重置内容」恢复的目标(与 initialDoc 独立)。
75
- * 编辑页打开已保存邮件时:initialDoc = 当前稿,presetDoc = 业务默认模板。
76
- * 未传时与 initialDoc 合并结果一致。
77
- */
78
- // presetDoc: defaultTemplatePartial,
79
- /** 右栏控件形态等,见下文「UI 选项 ui」 */
80
- // ui: { preferSliderControls: true, hideMailMeta: true },
81
- // autoWrapSection: true(默认)— 把 Block 拖到 Section 之间空白处时
82
- // 自动包一个一列 Section。设为 false 则强制只能拖入现有列内。
83
- // 唯一块被删或拖走后,会去掉因此变空的 Section,无需再删一次壳子。
216
+ // presetDoc: businessDefault, // 顶栏「重置内容」目标,见下文
217
+ // ui: { hideMailMeta: true, preferSliderControls: true },
84
218
  autoWrapSection: true,
85
- /**
86
- * 为 true 时:点击中栏灰色衬底、画布白底上未落到 Section/块的空白时,
87
- * 提交内联编辑并清空选中,右栏回到文档级面板。默认 false。
88
- */
89
- // clearSelectionOnCanvasMargin: true,
90
219
  onChange: (doc) => console.log(doc),
91
- /**
92
- * 可选:见 README「图片资源 imageAssets」(uploadImage、内置 imageGallery、自管 pickImageFromGallery)。
93
- */
94
- // imageAssets 见下方;演示见 playground(内置图库 + 侧栏上传)
95
- // imageAssets: { uploadImage, imageGallery: adapter, showGallery: true },
96
220
  });
97
221
 
98
222
  const { mjml, html } = editor.export({ withSampleVariables: true });
223
+ ```
99
224
 
100
- // 整份替换文档(例如切换模板);会先失焦右栏避免旧值残留
101
- // editor.setValue(nextDoc);
225
+ 构造后建议注入业务变量(避免只写在 `initialDoc.variables` 里后被空数组覆盖):
102
226
 
103
- // 清空画布(仅 sections 置空,保留 meta / styles / variables;可 ⌘Z 撤销)
104
- // editor.clearCanvas();
227
+ ```ts
228
+ editor.setVariables([
229
+ { key: 'username', label: '用户名', sample: '张三' },
230
+ { key: 'couponLink', label: '优惠券链接', kind: 'link', sample: '#' },
231
+ ]);
232
+ ```
105
233
 
106
- // 恢复为 presetDoc(或构造时 initialDoc)快照
107
- // editor.resetToPreset();
234
+ ---
108
235
 
109
- // 异步加载默认模板后更新重置目标
110
- // editor.setPresetDoc(defaultTemplatePartial);
111
- ```
236
+ ## MailEditor API 速览
237
+
238
+ | 方法 / 属性 | 说明 |
239
+ |-------------|------|
240
+ | `store` | 内部 Store,高级场景可读状态 |
241
+ | `registry` | 块注册表 |
242
+ | `getValue()` / `setValue(doc)` | 读写完整 `EmailDoc`;`setValue` 清空撤销栈 |
243
+ | `export({ withSampleVariables? })` | `{ mjml, html }` |
244
+ | `setVariables` / `getVariables` | 变量列表;`setValue` 后仍会写回 |
245
+ | `insertVariableKey` / `insertVariableElement` / `insertVariable` | 插入占位符 |
246
+ | `clearCanvas()` / `resetToPreset()` / `setPresetDoc(partial)` | 画布清空与重置 |
247
+ | `setTheme` / `getTheme` | `light` \| `dark` \| `system` |
248
+ | `setAccentColor` / `getAccentColor` | 品牌色 `#RRGGBB` |
249
+ | `copyDocDesign()` | 复制设计稿 JSON 到剪贴板 |
250
+ | `openImportDocDesign()` / `importDocDesignFromJson(raw)` | 导入设计稿 |
251
+ | `registerBlock(def)` | 运行时注册块 |
252
+ | `setSelection(sel \| null)` | 程序化选中 |
253
+ | `destroy()` | 卸载 DOM 与监听 |
254
+
255
+ 更多工具函数见 `@simple-mail/core` 导出(变量 HTML、动态变量节、`docClipboard`、`openImageGalleryModal` 等)。
112
256
 
113
- ### 画布清空与重置
257
+ ---
114
258
 
115
- 顶栏位于 **撤销 / 重做** 右侧(可用 `ui` 隐藏,见下表):
259
+ ## 画布清空与重置
260
+
261
+ 顶栏位于撤销/重做右侧(可用 `ui.hideTopbarClearCanvas` / `hideTopbarResetContent` 隐藏):
116
262
 
117
263
  | 按钮 | 行为 |
118
264
  |------|------|
119
- | **清空画布** | 移除所有 Section / Block;`meta`、`styles`、`variables` 不变。记入撤销栈(⌘Z 可恢复)。 |
120
- | **重置内容** | 整份替换为 **`presetDoc`** 快照(未传 `presetDoc` 时等同构造时的 `initialDoc` 合并结果)。**不**走撤销栈(与 `setValue` 相同,会清空 history)。 |
121
-
122
- **宿主常见写法**:新建页 `initialDoc` 与 `presetDoc` 同为默认模板;编辑页 `initialDoc` 为接口返回的 `jsonContent`,`presetDoc` 仍为业务预置结构,避免「重置」把用户带回打开时的草稿。
265
+ | **清空画布** | 移除所有 Section/Block;保留 `meta`、`styles`、`variables`;可撤销 |
266
+ | **重置内容** | 恢复为 **`presetDoc`** 快照;未传时等同构造时 `initialDoc` 合并结果 |
123
267
 
124
268
  ```ts
125
269
  const editor = new MailEditor({
126
270
  container: el,
127
271
  blocks: allBlocks,
128
- initialDoc: loadedFromApi, // 当前画布
129
- presetDoc: businessDefaultTemplate, // 顶栏「重置内容」
272
+ initialDoc: loadedFromApi,
273
+ presetDoc: businessDefaultTemplate,
130
274
  onChange: (doc) => save(doc),
131
275
  });
132
276
  ```
133
277
 
134
- ### 界面主题
278
+ ---
279
+
280
+ ## 界面主题与品牌色
135
281
 
136
- 构造参数 `theme?: 'light' | 'dark' | 'system'`(默认 `light`)。顶栏图标组与运行时 API 同步:
282
+ 构造参数 `theme?: 'light' | 'dark' | 'system'`(默认 `light`)。
137
283
 
138
284
  ```ts
139
285
  editor.setTheme('dark');
140
- editor.setTheme('system'); // 随系统明暗
141
286
  editor.getTheme();
142
287
  ```
143
288
 
144
- 根节点会设置 `data-sm-theme`,也可在宿主侧用 CSS 变量(`.sm-root` 上)覆盖配色。
145
-
146
- ### 品牌色(强调色)
147
-
148
- 界面主色对应 CSS 变量 `--sm-primary`、`--sm-primary-soft`(主按钮、选区、链接强调等)。不传则随 light/dark/system 使用内置紫/靛。
289
+ 根节点 `data-sm-theme`;可在宿主侧覆盖 `.sm-root` CSS 变量。
149
290
 
150
291
  | 方式 | 说明 |
151
292
  |------|------|
152
- | 构造参数 `accentColor?: string` | `#RRGGBB`(支持 3/6 位 hex);无效值会告警并忽略 |
153
- | 构造参数 `showAccentColorPicker?: boolean` | `true` 时在顶栏显示原生颜色控件;默认 `false` |
154
- | `editor.setAccentColor('#RRGGBB' \| null \| '')` | 运行时覆盖;`null` / 空字符串 表示恢复 CSS 默认 |
155
- | `editor.getAccentColor()` | 仅返回**当前显式覆盖**;未设置时为 `undefined` |
293
+ | `accentColor?: string` | 构造时 `#RRGGBB`;无效值告警并忽略 |
294
+ | `showAccentColorPicker?: boolean` | 顶栏原生颜色控件,默认 `false` |
295
+ | `setAccentColor(hex \| null \| '')` | 运行时覆盖;空则恢复默认 |
296
+ | `getAccentColor()` | 仅显式覆盖;未设返回 `undefined` |
156
297
 
157
- 当 `theme` 为 `system` 且设置了品牌色时,浅色/深色下的 soft 透明度会随 `prefers-color-scheme` 更新。
298
+ ---
158
299
 
159
- ### UI 选项 `ui`
300
+ ## UI 选项 `ui`
160
301
 
161
302
  `MailEditor` 的 `ui?: EditorUiOptions`:
162
303
 
163
304
  | 字段 | 说明 |
164
305
  |------|------|
165
- | `preferSliderControls?: boolean` | `true` 时右栏数值、内边距、全局/组件字号等使用滑块等增强控件。默认 `false`。 |
166
- | `hideMailMeta?: boolean` | 为 `true` 时:**不展示**右栏「主题」「Preheader」以及顶栏「邮件设置」按钮;右栏文档级面板改为 **「版式」(内容宽度)+「全局样式」**。`doc.meta.subject` / `preheader` 仍在数据模型中,导出 MJML 仍会生成 `<mj-title>`(可为空)、有值时才生成 `<mj-preview>`,适合发件主题由宿主系统单独维护的场景。 |
167
- | `hideTopbarTitle?: boolean` | 隐藏顶栏左侧产品标题(嵌入宿主页时常用)。 |
168
- | `hideTopbarMailSettings?: boolean` | 隐藏顶栏「邮件设置」按钮(与 `hideMailMeta` 叠加使用)。 |
169
- | `hideTopbarFullscreen?: boolean` | 隐藏顶栏全屏按钮。 |
170
- | `hideTopbarClearCanvas?: boolean` | 隐藏顶栏「清空画布」。 |
171
- | `hideTopbarResetContent?: boolean` | 隐藏顶栏「重置内容」。 |
172
-
173
- ### 构造选项(画布行为)
306
+ | `preferSliderControls?` | 右栏数值/内边距/宽度等用滑块增强控件;字重仍为五档平铺 |
307
+ | `hideMailMeta?` | 隐藏右栏主题、Preheader;文档级仅「版式 + 全局样式」 |
308
+ | `hideTopbarTitle?` | 隐藏顶栏产品标题 |
309
+ | `hideTopbarMailSettings?` | 隐藏顶栏「邮件设置」 |
310
+ | `hideTopbarFullscreen?` | 隐藏全屏按钮 |
311
+ | `hideTopbarClearCanvas?` / `hideTopbarResetContent?` | 隐藏清空 / 重置 |
312
+ | `hideTopbarDocClipboard?` | 隐藏「复制设计稿」「导入设计稿」 |
313
+ | `hideTopbarInsertVariable?` | 隐藏顶栏「插入变量」(由宿主提供时) |
314
+ | `topbarCompact?` | 顶栏默认仅图标(窄屏/嵌入) |
315
+ | `topbarLabels?` | `auto`(默认)\| `never` \| `always`;与 `topbarCompact` 配合 |
316
+ | `topbarCompactMinWidth?` | `topbarLabels: auto` 时展示文案的最小宽度 px,默认 1200 |
317
+ | `paletteBlockGroupTitle?` | 左栏合并分组标题,默认「组件」 |
318
+ | `customPaletteTooltipSuffix?` | custom 块无 `paletteTooltip` 时的 title 后缀 |
319
+ | `hiddenPaletteBlockTypes?` | 注册但不显示在左栏的 type(如组合块内部用的 divider) |
320
+ | `enableDynamicVariantKey?` | 开启 Section「动态变量名」、画布标识、palette 动态节;默认 `false` |
321
+
322
+ ---
323
+
324
+ ## 构造选项(画布行为)
174
325
 
175
326
  | 字段 | 说明 |
176
327
  |------|------|
177
- | `autoWrapSection?: boolean` | `true`(默认)时,把 Block 拖到 Section 之间空白处会自动包一列 Section;`false` 则只能拖入现有列内。 |
178
- | `clearSelectionOnCanvasMargin?: boolean` | `true` 时,点击中栏灰色衬底、画布白底留白(未点到 Section/Block)、空文档提示区等,会提交内联编辑并 `setSelection(null)`,右栏回到文档级面板。默认 `false`。嵌入宿主页且希望「点空白取消 focus」时开启。 |
328
+ | `container` | 挂载 DOM(必填) |
329
+ | `blocks?` | 块定义列表 |
330
+ | `engine?` | 目前仅 `'mjml'`;预留 `'table'` |
331
+ | `initialDoc?` | 首次进入画布 |
332
+ | `presetDoc?` | 「重置内容」目标 |
333
+ | `autoWrapSection?` | 默认 `true`:拖到 Section 间隙自动包单列 Section |
334
+ | `clearSelectionOnCanvasMargin?` | 点画布留白取消选中 |
335
+ | `theme?` / `accentColor?` / `showAccentColorPicker?` | 见上 |
336
+ | `imageAssets?` | 见 [图片资源](#图片资源-imageassets) |
337
+ | `onChange?` | 文档变更(防抖) |
179
338
 
180
- ### 变量系统
339
+ ---
181
340
 
182
- 占位符用于导出 HTML / MJML 后由后端或发送服务替换。设计态通过 `Variable` 列表维护可选项。
341
+ ## 变量系统
183
342
 
184
- #### 数据模型
343
+ ### 数据模型
185
344
 
186
345
  ```ts
187
346
  interface Variable {
188
- key: string; // Mustache 变量名,如 couponLink(不含 {{}})
189
- label: string; // 弹层 / 下拉展示名
190
- sample?: string; // 预览、export({ withSampleVariables: true }) 时的示例值
191
- kind?: 'text' | 'link' | 'image'; // 默认 text
347
+ key: string;
348
+ label: string;
349
+ sample?: string;
350
+ kind?: 'text' | 'link' | 'image';
192
351
  }
193
352
  ```
194
353
 
195
- | `kind` | 含义 | 「插入元素」行为 |
196
- |--------|------|----------------|
197
- | `text`(默认) | 纯文本占位 | 与插 key 相同,写入 `{{key}}` |
198
- | `link` | 链接类 | 插入 `<a href="{{key}}">{{key}}</a>`(href 与展示文本均为 token,不用 label) |
199
- | `image` | 图片类 | 插入 image 块,`src` 为 `{{key}}` |
354
+ | `kind` | 「插入元素」行为 |
355
+ |--------|------------------|
356
+ | `text` | `{{key}}` |
357
+ | `link` | `<a href="{{key}}">…</a>` |
358
+ | `image` | 新建 image 块,`src` 为 `{{key}}` |
200
359
 
201
- #### 注入与持久化
360
+ `setValue` / `resetToPreset` 后,通过 `setVariables` 注入的列表仍会写回 `doc.variables`。
202
361
 
203
- ```ts
204
- editor.setVariables([
205
- { key: 'username', label: '用户名', sample: '张三' },
206
- { key: 'couponLink', label: '优惠券链接', kind: 'link', sample: '#' },
207
- { key: 'couponImage', label: '优惠券图片', kind: 'image' },
208
- ]);
209
-
210
- editor.getVariables(); // 返回当前可用列表
211
- ```
212
-
213
- - 推荐宿主在构造后 **`setVariables`** 注入业务变量,而不是只写在 `initialDoc.variables` 里。
214
- - 宿主调用 **`setValue` / `resetToPreset`** 恢复文档 JSON 后,已通过 `setVariables` 注入的列表**仍会写回** `doc.variables`,避免弹层显示「暂无可用变量」。
215
-
216
- #### 插入 API
362
+ ### 插入 API
217
363
 
218
364
  | 方法 | 说明 |
219
365
  |------|------|
220
- | `insertVariableKey(v)` | 插入 `{{key}}` 纯文本 |
221
- | `insertVariableElement(v)` | `link` 链接 HTML;`image` 图片块;其余同 key |
222
- | `insertVariable(v)` | 同 `insertVariableKey`(兼容旧名) |
223
-
224
- 插入位置优先级:
225
-
226
- 1. 当前**内联编辑**(双击文本块)→ 写入 contenteditable 光标处(打开顶栏变量弹层前会自动 `saveSelection`)
227
- 2. 编辑器内**聚焦的 input/textarea**(右栏属性等)
228
- 3. 当前**选中的 Block** → 追加到其主文本字段末尾
229
- 4. 以上皆无 → 在画布末尾新建 text 块
230
-
231
- #### 顶栏弹层交互
232
-
233
- 顶栏 **`{{ }} 插入变量`** 打开列表,每行:
234
-
235
- | 操作 | 行为 |
236
- |------|------|
237
- | **点击行** | 插入 `{{key}}` |
238
- | **插入元素**(仅 `link` / `image`) | 调用 `insertVariableElement` |
239
- | **复制** | 复制 token 到剪贴板并关闭弹层 |
366
+ | `insertVariableKey(v)` | 纯文本 `{{key}}` |
367
+ | `insertVariableElement(v)` | link / image 片段 |
368
+ | `insertVariable(v)` | 同 `insertVariableKey`(兼容) |
240
369
 
241
- #### 宿主侧工具函数
370
+ 优先级:内联编辑光标 → 聚焦的 input/textarea → 选中 Block 主文本字段 → 末尾新建 text 块。
242
371
 
243
- 若主题、预览文案等字段在编辑器**外部**维护,可从 `@simple-mail/core` 导入:
372
+ ### 宿主工具函数
244
373
 
245
374
  ```ts
246
375
  import {
@@ -251,79 +380,96 @@ import {
251
380
  tokenToVariableKey,
252
381
  variablePlaceholder,
253
382
  } from '@simple-mail/core';
383
+ ```
254
384
 
255
- // key
256
- buildBodyVariableKeyInsert({ key: 'username', label: '用户名' });
257
- // -> { content: '{{username}}', asHtml: false }
385
+ `buildBodyVariableInsert` 仍可用,新代码请用 `buildBodyVariableKeyInsert` / `buildBodyVariableElementInsert`。
258
386
 
259
- // 链接 / 图片片段(供 Monaco、Grapes 等宿主自管插入)
260
- buildBodyVariableElementInsert(
261
- { key: 'couponLink', label: '优惠券链接', kind: 'link' },
262
- { linkColor: '#ff5a00' },
263
- );
264
- // link -> { content: '<a href="{{couponLink}}">...</a>', asHtml: true }
265
- ```
387
+ ### 入口分工(建议)
266
388
 
267
- `buildBodyVariableInsert` 仍可用,内部按 kind 分发到 key / element,**新代码请用上述两个函数**。
389
+ | 场景 | 入口 |
390
+ |------|------|
391
+ | 发件主题、Preheader 等 | **宿主页**插入变量 |
392
+ | 正文 | **编辑器顶栏** `{{ }}`(`hideTopbarInsertVariable` 时可关掉) |
268
393
 
269
- #### 嵌入宿主时的入口分工(建议)
394
+ ---
270
395
 
271
- | 字段 | 建议入口 |
272
- |------|----------|
273
- | 发件主题、Preheader 等表单字段 | **宿主页**下拉 / 输入框旁「插入变量」 |
274
- | 正文(已嵌入 `MailEditor`) | **编辑器顶栏** `{{ }}`(靠近光标,打开弹层前会保存选区) |
396
+ ## 动态变量节(dynamicVariant)
275
397
 
276
- 避免同一屏内正文出现两个变量入口;若宿主页仍保留正文入口,需自行在打开下拉前保存编辑器选区。
398
+ 用于「整节内容由宿主按 key 替换」的场景(如券包 `couponGroup`)。Section 设置 `attrs.dynamicVariantKey` 后,导出 HTML/MJML 时该节正文替换为 `{{key}}`;具体 HTML 由宿主写入。
277
399
 
278
- ### 图片资源 `imageAssets`
400
+ - 编辑器 UI 默认关闭;宿主设 `ui.enableDynamicVariantKey: true` 后,右栏可编辑 key,画布显示标识。
401
+ - 左栏 `expandPaletteDrop` 可返回 `{ blocks, sectionAttrs: { dynamicVariantKey: '…' } }` 一次拖入动态节。
402
+ - 已写入文档的 key **不依赖** UI 开关,导出仍生效。
279
403
 
280
- 编辑器**只把图片存成 props 里的 URL 字符串**(与 MJML `src` 一致),不负责对象存储落地。通过 `MailEditor` 的 **`imageAssets`** 合并配置。
404
+ 宿主可从 core 导入:
281
405
 
282
- #### 总览
406
+ ```ts
407
+ import {
408
+ DYNAMIC_VARIANT_HTML_ATTR,
409
+ dynamicVariantPlaceholder,
410
+ extractDynamicVariantSlots,
411
+ getSectionDynamicVariantKey,
412
+ isDynamicVariantSection,
413
+ renderSectionBodyHtml,
414
+ annotateDynamicVariantHtmlAttributes,
415
+ } from '@simple-mail/core';
416
+ ```
283
417
 
284
- | 字段 | 作用 |
285
- |------|------|
286
- | `uploadImage?: (file, ctx) => Promise<string>` | 属性面板「上传」→ 返回可插入邮件的 **HTTPS 绝对 URL**。 |
287
- | `imageGallery?: ImageGalleryAdapter` | **内置图库弹层**(搜索、分页、选图、可选「链接添加 / 弹层内上传」)。配合 `showGallery: true` 显示「图床」按钮。 |
288
- | `pickImageFromGallery?: (ctx) => Promise<string \| null>` | **完全自管**图床 UI;与 `imageGallery` 可并存,**同时存在时优先打开内置图库**。 |
289
- | `showUpload?` | 是否显示「上传」;仅当配置了 `uploadImage` 时有效,**默认 `true`**。 |
290
- | `showGallery?` | 是否显示「图床」;配置了 `imageGallery` **或** `pickImageFromGallery` 时有效,**默认 `false`**。 |
418
+ `Section.attrs.meta` 为宿主扩展袋,编辑器不解释,随 `EmailDoc` 序列化。
291
419
 
292
- `ImageFieldContext`(`blockId` / `propKey` / `currentUrl`)在 `uploadImage` 与 `pickImageFromGallery` 中传入,可从 `@simple-mail/core` 导入。
420
+ ---
293
421
 
294
- #### 内置图库 `ImageGalleryAdapter`
422
+ ## 设计稿剪贴板
295
423
 
296
- 宿主实现数据与业务,**不写弹框 DOM**。弹层样式根节点为 `.sm-gallery-modal`,可通过 **CSS 变量** 覆盖主题,例如:
424
+ 顶栏 **复制设计稿** / **导入设计稿**(`ui.hideTopbarDocClipboard` 可隐藏)。
297
425
 
298
- ```css
299
- .sm-gallery-modal {
300
- --sm-gallery-cell-bg: #f0f4f8;
301
- --sm-gallery-thumb-h: 96px;
302
- }
426
+ - 复制:信封 `{ kind: 'simple-mail/doc', formatVersion: 1, doc }` 写入系统剪贴板。
427
+ - 导入:粘贴 JSON 或裸 `EmailDoc`(`version: '1'`),覆盖当前画布(可撤销)。
428
+
429
+ ```ts
430
+ await editor.copyDocDesign();
431
+ editor.openImportDocDesign();
432
+ editor.importDocDesignFromJson(raw); // 与对话框「应用」相同
433
+
434
+ import {
435
+ DOC_CLIPBOARD_KIND,
436
+ parseDocClipboard,
437
+ regenerateDocIds,
438
+ serializeDocClipboard,
439
+ } from '@simple-mail/core';
303
440
  ```
304
441
 
305
- | 方法 | 必选 | 说明 |
306
- |------|:----:|------|
307
- | `listItems({ query, page })` | ✅ | `query` 为搜索框文本;`page` 从 **0** 起。返回 `{ items: GalleryItem[], hasMore }`。 |
308
- | `uploadFile?(file)` | | 若提供,工具栏显示「**上传**」(与搜索、添加同一行);完成后重新请求第 0 页。 |
309
- | `addByUrl?(url)` | | 若提供,工具栏显示链接输入与「**添加**」;完成后重新请求第 0 页;校验/落库由宿主完成,失败请 `throw`。 |
310
- | `deleteItem?(id)` | | 若提供,每张缩略图右上角可删除;成功后重新请求第 0 页;失败请 `throw`。 |
442
+ 跨实例迁移时可用 `regenerateDocIds` 避免 id 冲突。
311
443
 
312
- 搜索框、链接输入、「添加」「上传」在**同一行**展示(窄屏下自动换行)。
444
+ ---
313
445
 
314
- `GalleryItem`:`id`、`url`(写入字段的最终地址)、可选 `thumbnailUrl`、`title`。
446
+ ## 图片资源 `imageAssets`
315
447
 
316
- 进阶:若需在任意时机主动打开同一套 UI,可导入 **`openImageGalleryModal({ adapter, onPick, parent?, onClose? })`**(类型 `OpenImageGalleryModalOptions`)。
448
+ 编辑器只存 URL;落地由宿主实现。
317
449
 
318
- #### 与自管图库的关系
450
+ | 字段 | 作用 |
451
+ |------|------|
452
+ | `uploadImage?(file, ctx)` | 右栏「上传」→ HTTPS URL |
453
+ | `imageGallery?` | 内置图库弹层(`showGallery: true`) |
454
+ | `pickImageFromGallery?(ctx)` | 自管图床;与 `imageGallery` 并存时**优先内置图库** |
455
+ | `showUpload?` | 默认 `true`(有 `uploadImage` 时) |
456
+ | `showGallery?` | 默认 `false` |
319
457
 
320
- 仅需要自有弹层时:只配 `pickImageFromGallery` + `showGallery: true`。
321
- 需要本库弹层时:配 `imageGallery` + `showGallery: true`。
322
- 若两者都配,点击「图床」**走 `imageGallery`**。
458
+ `ImageFieldContext`:`blockId` / `propKey` / `currentUrl`。
323
459
 
324
- **注意:** 邮件中图片需**公网可访问** URL;`data:` / CID 需另扩 MJML。`playground` picsum 仅作演示。
460
+ 内置图库实现 `ImageGalleryAdapter`:`listItems({ query, page })`(`page` 从 0 起)、可选 `uploadFile` / `addByUrl` / `deleteItem`。弹层根 `.sm-gallery-modal`,可用 CSS 变量改主题。
325
461
 
326
- ### React 集成(最小封装示意)
462
+ ```ts
463
+ import { openImageGalleryModal } from '@simple-mail/core';
464
+ ```
465
+
466
+ 邮件图片需公网 URL;`data:` / CID 需自行扩展 MJML。
467
+
468
+ ---
469
+
470
+ ## 框架集成示例
471
+
472
+ ### React
327
473
 
328
474
  ```tsx
329
475
  import { useEffect, useRef } from 'react';
@@ -331,8 +477,13 @@ import { MailEditor, type EmailDoc } from '@simple-mail/core';
331
477
  import '@simple-mail/core/style.css';
332
478
  import { allBlocks } from '@simple-mail/blocks';
333
479
 
334
- export function MailEditorView({ value, onChange }: {
335
- value?: EmailDoc;
480
+ export function MailEditorView({
481
+ value,
482
+ presetDoc,
483
+ onChange,
484
+ }: {
485
+ value?: Partial<EmailDoc>;
486
+ presetDoc?: Partial<EmailDoc>;
336
487
  onChange?: (d: EmailDoc) => void;
337
488
  }) {
338
489
  const ref = useRef<HTMLDivElement>(null);
@@ -341,6 +492,8 @@ export function MailEditorView({ value, onChange }: {
341
492
  container: ref.current!,
342
493
  blocks: allBlocks,
343
494
  initialDoc: value,
495
+ presetDoc,
496
+ ui: { hideTopbarTitle: true },
344
497
  onChange,
345
498
  });
346
499
  return () => editor.destroy();
@@ -349,7 +502,7 @@ export function MailEditorView({ value, onChange }: {
349
502
  }
350
503
  ```
351
504
 
352
- ### Vue 集成(最小封装示意)
505
+ ### Vue 3
353
506
 
354
507
  ```vue
355
508
  <script setup lang="ts">
@@ -358,14 +511,17 @@ import { MailEditor, type BlockDefinition, type EmailDoc } from '@simple-mail/co
358
511
  import '@simple-mail/core/style.css';
359
512
  import { allBlocks } from '@simple-mail/blocks';
360
513
 
361
- const props = defineProps<{ modelValue?: EmailDoc; blocks?: BlockDefinition<any>[] }>();
514
+ const props = defineProps<{
515
+ modelValue?: Partial<EmailDoc>;
516
+ presetDoc?: Partial<EmailDoc>;
517
+ blocks?: BlockDefinition<any>[];
518
+ }>();
362
519
  const emit = defineEmits<{ 'update:modelValue': [EmailDoc] }>();
363
520
  const el = ref<HTMLDivElement>();
364
521
  let editor: MailEditor | null = null;
365
522
 
366
523
  onMounted(() => {
367
524
  const defs = props.blocks ?? allBlocks;
368
- // expandPaletteDrop / schema 等依赖块定义上的函数;props 深度代理可能导致丢失,建议 markRaw
369
525
  const stable = defs.map((d) => markRaw(d));
370
526
  editor = new MailEditor({
371
527
  container: el.value!,
@@ -376,172 +532,174 @@ onMounted(() => {
376
532
  clearSelectionOnCanvasMargin: true,
377
533
  onChange: (doc) => emit('update:modelValue', doc),
378
534
  });
379
- editor.setVariables([
380
- { key: 'username', label: '用户名' },
381
- { key: 'couponLink', label: '优惠券链接', kind: 'link' },
382
- ]);
383
535
  });
384
536
  onBeforeUnmount(() => editor?.destroy());
385
537
  </script>
386
538
 
387
539
  <template>
388
- <div ref="el" style="height: 100vh" />
540
+ <div ref="el" class="simple-mail-editor-host" style="height: 100vh" />
389
541
  </template>
390
542
  ```
391
543
 
392
- ## 自定义组件
544
+ `blocks` 经 Vue 响应式代理时,`expandPaletteDrop` 等函数字段可能失效,务必 **`markRaw`** 每个 `BlockDefinition`。
545
+
546
+ ---
393
547
 
394
- `defineBlock` 是注册表的入口:
548
+ ## 自定义组件
395
549
 
396
550
  ```ts
397
551
  import { defineBlock } from '@simple-mail/core';
398
552
 
399
- export const couponBlock = defineBlock<{ title: string; code: string; expiresAt: string }>({
553
+ export const couponBlock = defineBlock<{ title: string; code: string }>({
400
554
  type: 'custom:coupon',
401
555
  name: '优惠券',
402
556
  category: 'custom',
403
- icon: '<svg width="24" height="24"...>...</svg>',
404
- defaultProps: { title: '专属优惠', code: 'WELCOME10', expiresAt: '2026-12-31' },
557
+ icon: '<svg width="24" height="24">…</svg>',
558
+ defaultProps: { title: '专属优惠', code: 'WELCOME10' },
405
559
  schema: [
406
560
  { key: 'code', label: '券码', type: 'text' },
407
- { key: 'expiresAt', label: '到期日', type: 'text' },
561
+ { key: 'title', label: '标题', type: 'text', inheritGlobal: true },
408
562
  ],
409
- // 让 title 字段支持画布内双击编辑(可选)
410
563
  inlineEditable: {
411
564
  selector: '.coupon-title',
412
- mode: 'rich', // 'rich' = HTML, 'plain' = textContent
565
+ mode: 'rich',
413
566
  multiline: false,
414
567
  propKey: 'title',
415
- placeholder: '点这里输入标题',
568
+ placeholder: '双击编辑',
416
569
  },
417
- // 真实邮件输出(MJML)
418
- toMjml: (p) => `<mj-text align="center" font-size="20px" font-weight="700">
419
- <span class="coupon-title">${p.title}</span>
420
- 🎁 ${p.code} <small>(到期 ${p.expiresAt})</small>
421
- </mj-text>`,
422
- // 设计态画布的轻量预览(可选)。inlineEditable.selector 必须能命中此预览中的元素。
423
- renderPreview: (p) => `<div style="text-align:center;padding:16px;font-weight:700;font-size:20px;">
424
- <span class="coupon-title">${p.title}</span>
425
- 🎁 ${p.code} <small>(到期 ${p.expiresAt})</small>
426
- </div>`,
570
+ toMjml: (p) => `<mj-text>…</mj-text>`,
571
+ renderPreview: (p) => `<div class="coupon-title">…</div>`,
427
572
  });
428
573
 
429
574
  new MailEditor({ container, blocks: [...allBlocks, couponBlock] });
430
575
  ```
431
576
 
432
- `schema` 字段类型支持:`text | textarea | number | color | select | switch | image | url | spacing | socialLinkList`。其中 **`image`** 渲染为「URL 输入 +(可选)上传 +(可选)图库」,由 `MailEditor` 的 **`imageAssets`** 控制,见上文「图片资源 imageAssets」。
433
- **社交组**(`social-group`)另有 **`iconBorderRadius`**(px,默认圆形)、**`iconSpacing`**(图标间距 px,对应 MJML `mj-social` 的 `inner-padding`),画布预览与导出共用同一套圆角/间距逻辑。
434
- 所有字段会在右栏自动渲染表单,change 事件回写 `block.props`。
577
+ ### `schema` 字段类型
435
578
 
436
- ### 内联编辑 inlineEditable
579
+ `text | textarea | number | color | select | switch | image | url | spacing | socialLinkList`
437
580
 
438
- | 字段 | 说明 |
439
- |---|---|
440
- | `selector` | 可选;在 `renderPreview` 输出 DOM 中定位编辑区域。不填则取根元素 |
441
- | `mode` | `rich`(保留 HTML,配合富文本工具条)/ `plain`(纯文本,单行/多行) |
442
- | `multiline` | `false` 时回车提交且禁止换行(按钮文字常用) |
443
- | `propKey` | 提交时把 innerHTML(rich)或 textContent(plain)写回该 key |
444
- | `placeholder` | 当字段为空时显示的占位提示 |
445
-
446
- 提交时机:失焦 / 单行 Enter / Esc 取消。富文本提交前会过一次白名单清洗(保留 `a/b/strong/i/em/u/s/span/p/div/ul/ol/li/h1-h6/br`,属性仅留 `href/target/rel/style/class`,`<font>` 自动转为 `<span style="...">`)。
581
+ - `selectVariant: 'segmented'`:少量互斥选项(如对齐)
582
+ - `inheritGlobal: true`:右栏「继承全局」开关
583
+ - `image`:由 `imageAssets` 控制上传/图库
447
584
 
448
- ### 左栏组合模板 `expandPaletteDrop`(可选)
585
+ ### `inlineEditable`
449
586
 
450
- 用于「一次拖入、画布内仍是多个通用块」的预设(例如页脚 = 图片 + 若干文本),避免整段 raw MJML,又省去运营逐个从左侧拖组件。
587
+ | 字段 | 说明 |
588
+ |------|------|
589
+ | `selector?` | 在 `renderPreview` DOM 中定位;缺省为根 |
590
+ | `mode` | `rich`(清洗 HTML)\| `plain` \| `html`(原始 HTML,mj-raw) |
591
+ | `multiline?` | `false` 时 Enter 提交 |
592
+ | `propKey` | 写回的 props 键 |
593
+ | `placeholder?` | 空值提示 |
451
594
 
452
- `BlockDefinition` 上可选声明:
595
+ ### `expandPaletteDrop`(组合拖入)
453
596
 
454
597
  ```ts
455
- expandPaletteDrop?: (createBlock: (type: string) => Block) => Block[];
598
+ expandPaletteDrop?: (createBlock) => Block[] | { blocks: Block[]; sectionAttrs?: Partial<SectionAttrs> };
456
599
  ```
457
600
 
458
- - 从左栏拖入该条目时,引擎会在目标列(或拖到 Section 间隙时自动包裹的**单列 Section**)内 **`splice` 插入**回调返回的多个块;**文档 JSON 里不会出现该定义的 `type`**,仅在注册表中作为左栏卡片存在。
459
- - 回调内请使用传入的 `createBlock('image' | 'text' | …)`,以便 ID、`defaultProps` 与内置块一致。
460
- - `toMjml` / `renderPreview` 仍须在类型上满足 `BlockDefinition`;组合入口可选用占位的 `toMjml: () => ''`(正常不应出现在 `sections` 里)。
601
+ 从左栏拖入时展开为多个通用块;文档 JSON **不出现**该 palette `type`。拖到 Section 间隙时可带 `sectionAttrs`(含 `dynamicVariantKey`、`meta`)。
461
602
 
462
- **宿主集成注意**
603
+ ---
463
604
 
464
- - 修改 `packages/core` 源码(含拖拽、`expandPaletteDrop` 等)后,请在 monorepo 根目录执行 **`pnpm build`** 或 **`pnpm --filter @simple-mail/core build`**,保证 npm/link 宿主的 **`dist`** 与类型声明同步。
465
- - **Vue**:若把 `blocks` 数组作为 **props** 传入再交给 `MailEditor`,响应式代理可能导致块定义上的**函数字段**不可靠;应对每个 `BlockDefinition` 使用 **`markRaw`**(或与之一致的「非响应式」引用)后再传入构造器。参见上文「Vue 集成」示例注释。
466
- - **Vite**:宿主若通过 **`link:` / workspace** 引用本仓库包,建议将 **`@simple-mail/core`、`@simple-mail/blocks`**(以及项目中实际 import 的等价路径,如指向本 monorepo 子包的 specifier)列入 **`optimizeDeps.exclude`**,避免 **依赖预构建缓存**与本地刚构建的 **`dist`** 不一致(常见现象:画布拖拽、`expandPaletteDrop` 等与当前源码不符)。仍异常时可删除宿主项目的 **`node_modules/.vite`** 后重启 dev。
605
+ ## 宿主集成注意
606
+
607
+ 1. core 源码后 **`pnpm build`**,再刷新宿主。
608
+ 2. **Vue**:`blocks` 用 `markRaw`。
609
+ 3. **Vite link**:`optimizeDeps.exclude` 含 `@simple-mail/core`、`@simple-mail/blocks`;配置 alias 与 codemirror/mjml/sortablejs 解析。
610
+ 4. **import 路径**:统一 `@simple-mail/*`,避免长期依赖 `mail-editor-pancake/packages/...`。
611
+ 5. 宿主需安装与 core 一致的 **codemirror、mjml-browser、sortablejs**(link 时尤甚)。
612
+
613
+ ---
467
614
 
468
615
  ## 操作手册
469
616
 
470
617
  | 操作 | 触发方式 |
471
- |---|---|
472
- | 选中 Section | 单击 Section 空白处 |
473
- | 选中 Block | 单击 Block |
474
- | **编辑 Block 内容** | **双击 Block**(文本/按钮);或选中后点 图标 |
475
- | 编辑中提交 | 点击外部 / 单行 Enter / 按工具条按钮后失焦 |
476
- | 编辑中取消 | Esc |
477
- | 拖拽排序 | 仅在 hover 出现的 ⋮⋮ 图标上按住拖动 |
478
- | 删除 | 选中后按 Delete/Backspace;或工具条 🗑 |
479
- | 复制 | 工具条 图标 |
480
- | 撤销 / 重做 | ⌘Z / ⌘⇧Z(顶栏按钮也行) |
481
- | 清空画布 | 顶栏「清空画布」;移除全部 Section/Block,保留全局样式与变量(可撤销) |
482
- | 重置内容 | 顶栏「重置内容」;恢复为 `presetDoc`(或构造时的 `initialDoc`) |
483
- | 插入变量 key | 顶栏 `{{ }}` → **点击行**;或 `editor.insertVariableKey(v)` |
484
- | 插入变量元素 | 顶栏 `{{ }}`**插入元素**(仅 `link` / `image`);或 `editor.insertVariableElement(v)` |
485
- | 复制变量 token | 顶栏 `{{ }}` → **复制** |
486
- | 点空白取消选中 | `clearSelectionOnCanvasMargin: true`;点击画布灰色衬底或白底留白 |
487
- | 切换源码 / 设计 | 顶栏切换 |
488
- | 导出 HTML | 顶栏右上 |
489
- | 文档级设置(主题 / Preheader / 宽度 / 全局样式) | 未选中画布时右栏展示;`ui.hideMailMeta` 时无主题与 Preheader,且无顶栏「邮件设置」按钮 |
618
+ |------|----------|
619
+ | 选中 Section / Block | 单击 |
620
+ | 编辑内容 | 双击;或选中后 |
621
+ | 提交 / 取消编辑 | 失焦、单行 Enter / Esc |
622
+ | 列表 | Shift+Enter 软换行;空项 Enter 退出列表 |
623
+ | 拖拽排序 | ⋮⋮ 图标 |
624
+ | 删除 | Delete/Backspace 或工具条 |
625
+ | 复制块/节 | 工具条 |
626
+ | 撤销/重做 | ⌘Z / ⌘⇧Z |
627
+ | 清空 / 重置 | 顶栏按钮或 API |
628
+ | 变量 | 顶栏 `{{ }}` 或 API |
629
+ | 设计稿 | 顶栏复制/导入 |
630
+ | 点空白取消选中 | `clearSelectionOnCanvasMargin: true` |
631
+ | Esc(设计态) | Section文档级面板 |
632
+ | 源码/设计切换 | 顶栏 |
633
+ | 导出 HTML | 顶栏 |
634
+
635
+ ---
490
636
 
491
637
  ## 设计与代码模式
492
638
 
493
- - **设计模式**:默认。三栏,所见即所得(画布是轻量预览,非真实邮件 HTML)。
494
- - **源码模式(顶栏切换)**:只读展示当前文档的 MJML MJML 编译产物 HTML,可一键复制。切到源码会自动提交内联编辑。
495
- - **组件级代码(右栏 "代码" Tab)**:编辑选中 Block 的 MJML 片段并保存为 `lockedMjml`,
496
- 之后该组件不再走 `toMjml(props)`,属性面板被禁用,可点"恢复默认"取消锁定。
497
- - **导出 HTML**:顶栏右上角,下载经 `withSampleVariables` 替换后的 HTML 文件。
498
-
499
- ## 数据模型 速览
639
+ - **设计模式**:三栏 WYSIWYG 预览(非真实邮件 HTML)。
640
+ - **源码模式**:只读整份 MJML + 编译 HTML,可复制。
641
+ - **组件级代码**(右栏「代码」Tab):编辑 `lockedMjml`,禁用属性面板,可恢复默认。
642
+ - **导出 HTML**:顶栏下载,`withSampleVariables` 替换示例值。
500
643
 
501
- `EmailDoc` 为单一事实来源;设计 / 导出 / 宿主保存均围绕该 JSON。
644
+ ---
502
645
 
503
- - **`initialDoc`**:`Partial<EmailDoc>`,与默认空邮件合并,作为**首次进入画布**的内容。
504
- - **`presetDoc`**:可选,与默认空邮件合并,作为顶栏 **「重置内容」** 的目标;未传时与 `initialDoc` 相同。编辑已保存邮件时应单独传入业务默认模板,勿与 `initialDoc` 混用。
505
- - **`editor.setValue(doc)`**:运行期整份替换(如切换模板);会清空撤销栈;调用前会尽量失焦右栏输入。
506
- - **`editor.clearCanvas()`** / **`editor.resetToPreset()`** / **`editor.setPresetDoc(partial)`**:与顶栏按钮等价,供宿主程序化调用。
646
+ ## 数据模型速览
507
647
 
508
648
  ```ts
509
649
  interface EmailDoc {
510
650
  version: '1';
511
651
  meta: { subject: string; preheader?: string; width: number | string };
512
- variables: { key: string; label: string; sample?: string; kind?: 'text' | 'link' | 'image' }[];
513
- styles: { backgroundColor; contentBackgroundColor; fontFamily; fontSize; color; linkColor; lineHeight };
514
- sections: Section[]; // 顺序即视觉顺序
652
+ variables: Variable[];
653
+ styles: GlobalStyles; // fontWeight 档位、listIndentDefaultPx
654
+ sections: Section[];
655
+ }
656
+
657
+ interface Section {
658
+ id: string;
659
+ type: 'section';
660
+ layout: '1' | '1-1' | '1-2' | '2-1' | '1-1-1';
661
+ attrs: SectionAttrs; // padding、preserveColumnsOnMobile、columnGap、width、dynamicVariantKey、meta
662
+ columns: Column[];
663
+ }
664
+
665
+ interface Block {
666
+ id: string;
667
+ type: string;
668
+ props: Record<string, unknown>;
669
+ lockedMjml?: string;
515
670
  }
516
- interface Section { id; type: 'section'; layout: '1'|'1-1'|'1-2'|'2-1'|'1-1-1'; attrs; columns: Column[] }
517
- // attrs.preserveColumnsOnMobile:多列时可设 true,MJML 包 mj-group,小屏仍为并排列;默认/未设则小屏堆叠列
518
- // attrs.width:本节最大宽度(px 或 %),窄于邮件宽度时居中;不设则同邮件 meta.width
519
- // attrs.columnGap:多列时列间距 (px),MJML 通过列对称 padding 实现;单列无效
520
- interface Column { id; attrs; blocks: Block[] }
521
- interface Block { id; type; props; lockedMjml? }
522
671
  ```
523
672
 
673
+ - `initialDoc`:首次画布。
674
+ - `presetDoc`:重置目标;编辑已保存邮件时勿与 `initialDoc` 混为一谈。
675
+
676
+ ---
677
+
524
678
  ## 路线图
525
679
 
526
- - [x] M1 · 渲染管线(Schema → MJML → HTML)+ 内置组件
527
- - [x] M2 · 三栏 UI、两级拖拽、撤销/重做、键盘快捷键
528
- - [x] M3 · 自定义组件、变量系统、组件级 + 文档级代码模式
529
- - [ ] M4 · `@simple-mail/react` / `@simple-mail/vue` 适配器包发布
530
- - [ ] iframe 实时预览(Outlook 桌面端 ghost padding 还原)
531
- - [ ] 邮件模板市场(保存/加载 EmailDoc JSON)
680
+ - [x] M1 · Schema → MJML → HTML + 内置组件
681
+ - [x] M2 · 三栏 UI、拖拽、撤销/重做
682
+ - [x] M3 · 自定义块、变量、代码模式、设计稿剪贴板、动态变量节
683
+ - [ ] M4 · `@simple-mail/react` / `@simple-mail/vue` 适配器
684
+ - [ ] iframe 预览(Outlook ghost padding
685
+ - [ ] 模板市场(EmailDoc JSON)
532
686
  - [ ] i18n(zh-CN / en)
533
687
 
688
+ ---
689
+
534
690
  ## 设计取舍
535
691
 
536
692
  | 取舍点 | 选择 | 原因 |
537
- |---|---|---|
538
- | 画布是否用真实邮件 HTML | 走轻量预览 DOM | SortableJS 不能跨 iframe;交互可控更重要。真实预览/导出由 MJML 兜底 |
539
- | 组件树是否允许任意嵌套 | 严格 4 层 | 这是 GrapesJS 邮件场景翻车根因 |
540
- | 输出引擎 | MJML 优先,预留 `engine: 'table'` | Outlook/Gmail 兼容性最稳。需要更轻可切换为手写 table |
541
- | 文档级源码可否回写 | 只读 | MJML/HTML 反推 doc 风险大;如需直写源码请用组件级代码模式 |
542
- | 状态管理 | 自实现 immutable + history | 核心包零运行时依赖(除功能性依赖) |
543
- | 包体 | gzip ≈ 580KB | 主要来自 mjml-browser,后续 lazy-load 优化 |
544
- | 默认内边距 | Section 左右 16、上下 0;内容块上下 8、左右 0 | 横向版心由节统一;块只堆纵向节奏,避免左右重复缩进 |
693
+ |--------|------|------|
694
+ | 画布 HTML | 轻量预览 DOM | iframe Sortable 难控;真实效果靠 MJML |
695
+ | 嵌套 | 严格四层 | 避免 GrapesJS 式邮件翻车 |
696
+ | 输出 | MJML 优先 | Outlook/Gmail 兼容;可预留 table 引擎 |
697
+ | 文档级源码回写 | 只读 | MJML→doc 反推风险大 |
698
+ | 状态 | immutable + history | 核心零 UI 框架依赖 |
699
+ | 包体 | gzip ≈ 580KB | 主因 mjml-browser |
700
+ | 默认内边距 | Section 左右 16;块上下 8 | 版心由节统一 |
701
+
702
+ ---
545
703
 
546
704
  ## 许可证
547
705