mail-editor-pancake 0.2.7 → 0.3.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 (60) hide show
  1. package/README.md +37 -4
  2. package/package.json +1 -1
  3. package/packages/blocks/dist/index.js +275 -263
  4. package/packages/blocks/dist/index.js.map +1 -1
  5. package/packages/blocks/package.json +1 -3
  6. package/packages/blocks/src/button.ts +1 -1
  7. package/packages/blocks/src/hero.ts +6 -3
  8. package/packages/blocks/src/i18n.ts +10 -11
  9. package/packages/blocks/src/image.ts +2 -1
  10. package/packages/blocks/src/index.ts +5 -3
  11. package/packages/blocks/src/social.ts +3 -3
  12. package/packages/blocks/src/socialGroup.ts +4 -4
  13. package/packages/blocks/src/socialShared.ts +23 -3
  14. package/packages/blocks/src/text.ts +8 -4
  15. package/packages/blocks/vite.config.ts +1 -1
  16. package/packages/core/dist/index.d.ts +96 -1
  17. package/packages/core/dist/index.js +4349 -3594
  18. package/packages/core/dist/index.js.map +1 -1
  19. package/packages/core/dist/style.css +1 -1
  20. package/packages/core/package.json +1 -3
  21. package/packages/core/src/editor/BlockCodeModal.ts +6 -7
  22. package/packages/core/src/editor/Canvas.ts +559 -21
  23. package/packages/core/src/editor/ColorPickerPopover.ts +2 -2
  24. package/packages/core/src/editor/Editor.ts +381 -41
  25. package/packages/core/src/editor/ExportModal.ts +7 -9
  26. package/packages/core/src/editor/FocusBreadcrumb.ts +3 -3
  27. package/packages/core/src/editor/ImportDocModal.ts +50 -7
  28. package/packages/core/src/editor/InlineEditor.ts +43 -39
  29. package/packages/core/src/editor/LeftPanel.ts +2 -4
  30. package/packages/core/src/editor/Modal.ts +1 -1
  31. package/packages/core/src/editor/PreviewModal.ts +3 -8
  32. package/packages/core/src/editor/RichTextToolbar.ts +26 -15
  33. package/packages/core/src/editor/RightPanel.ts +142 -126
  34. package/packages/core/src/editor/SourceView.ts +5 -3
  35. package/packages/core/src/editor/Topbar.ts +79 -34
  36. package/packages/core/src/editor/VariablePickerPanel.ts +3 -5
  37. package/packages/core/src/editor/imageAssets.ts +1 -1
  38. package/packages/core/src/editor/styles.css +258 -86
  39. package/packages/core/src/i18n/index.ts +139 -41
  40. package/packages/core/src/index.ts +11 -1
  41. package/packages/core/src/renderer/index.ts +3 -6
  42. package/packages/core/src/renderer/mjml.ts +5 -10
  43. package/packages/core/src/store/store.ts +2 -6
  44. package/packages/core/src/types.ts +3 -3
  45. package/packages/core/src/utils/accentColor.ts +3 -3
  46. package/packages/core/src/utils/docClipboard.ts +107 -2
  47. package/packages/core/src/utils/dynamicVariantHtml.ts +1 -1
  48. package/packages/core/src/utils/emailListStyles.ts +1 -1
  49. package/packages/core/src/utils/fontWeightSteps.ts +7 -6
  50. package/packages/core/src/utils/html.ts +3 -2
  51. package/packages/core/src/utils/inlineListEditing.ts +19 -35
  52. package/packages/core/src/utils/link.ts +3 -6
  53. package/packages/core/src/utils/lockedMjml.ts +2 -2
  54. package/packages/core/src/utils/modalSize.ts +4 -3
  55. package/packages/core/src/utils/richHtmlEmpty.ts +3 -1
  56. package/packages/core/src/variables/index.ts +4 -5
  57. package/packages/core/vite.config.ts +1 -1
  58. package/playground/vanilla/index.html +17 -0
  59. package/playground/vanilla/src/main.ts +152 -123
  60. package/playground/vanilla/vite.config.ts +5 -2
@@ -1,16 +1,33 @@
1
- import type { EmailDoc, Section } from '../types';
1
+ import type { Block, EmailDoc, Section, Variable } from '../types';
2
2
  import { uid } from './id';
3
3
 
4
4
  /** 剪贴板 / 文件 interchange 信封标识 */
5
5
  export const DOC_CLIPBOARD_KIND = 'simple-mail/doc' as const;
6
6
  export const DOC_CLIPBOARD_FORMAT_VERSION = 1 as const;
7
7
 
8
+ /** 局部设计稿(Section / Block 子集)剪贴板信封标识 */
9
+ export const SELECTION_CLIPBOARD_KIND = 'simple-mail/selection' as const;
10
+ export const SELECTION_CLIPBOARD_FORMAT_VERSION = 1 as const;
11
+
8
12
  export interface DocClipboardEnvelope {
9
13
  kind: typeof DOC_CLIPBOARD_KIND;
10
14
  formatVersion: typeof DOC_CLIPBOARD_FORMAT_VERSION;
11
15
  doc: EmailDoc;
12
16
  }
13
17
 
18
+ /**
19
+ * 局部设计稿信封:携带若干 Section 与若干独立 Block。
20
+ * 粘贴时按「追加」语义落到目标画布,不覆盖现有内容。
21
+ */
22
+ export interface SelectionClipboardEnvelope {
23
+ kind: typeof SELECTION_CLIPBOARD_KIND;
24
+ formatVersion: typeof SELECTION_CLIPBOARD_FORMAT_VERSION;
25
+ /** 源文档变量列表;粘贴时仅合并被内容引用到的 key */
26
+ variables: Variable[];
27
+ sections: Section[];
28
+ blocks: Block[];
29
+ }
30
+
14
31
  export function serializeDocClipboard(doc: EmailDoc): string {
15
32
  const envelope: DocClipboardEnvelope = {
16
33
  kind: DOC_CLIPBOARD_KIND,
@@ -20,6 +37,22 @@ export function serializeDocClipboard(doc: EmailDoc): string {
20
37
  return JSON.stringify(envelope);
21
38
  }
22
39
 
40
+ /** 序列化局部设计稿(选中的 Section / Block)为剪贴板 JSON。 */
41
+ export function serializeSelectionClipboard(input: {
42
+ sections?: Section[];
43
+ blocks?: Block[];
44
+ variables?: Variable[];
45
+ }): string {
46
+ const envelope: SelectionClipboardEnvelope = {
47
+ kind: SELECTION_CLIPBOARD_KIND,
48
+ formatVersion: SELECTION_CLIPBOARD_FORMAT_VERSION,
49
+ variables: input.variables ?? [],
50
+ sections: input.sections ?? [],
51
+ blocks: input.blocks ?? [],
52
+ };
53
+ return JSON.stringify(envelope);
54
+ }
55
+
23
56
  function isRecord(v: unknown): v is Record<string, unknown> {
24
57
  return typeof v === 'object' && v !== null && !Array.isArray(v);
25
58
  }
@@ -66,6 +99,54 @@ export function parseDocClipboard(raw: string): EmailDoc | null {
66
99
  return null;
67
100
  }
68
101
 
102
+ function isVariableShape(v: unknown): v is Variable {
103
+ if (!isRecord(v)) return false;
104
+ return typeof v.key === 'string' && typeof v.label === 'string';
105
+ }
106
+
107
+ /**
108
+ * 解析局部设计稿信封(`simple-mail/selection`)。
109
+ * 仅做形状校验,不做未注册类型过滤(由编辑器粘贴时按 registry 处理)。
110
+ */
111
+ export function parseSelectionClipboard(raw: string): SelectionClipboardEnvelope | null {
112
+ const trimmed = (raw || '').trim();
113
+ if (!trimmed) return null;
114
+ let parsed: unknown;
115
+ try {
116
+ parsed = JSON.parse(trimmed);
117
+ } catch {
118
+ return null;
119
+ }
120
+ if (!isRecord(parsed)) return null;
121
+ if (parsed.kind !== SELECTION_CLIPBOARD_KIND) return null;
122
+ if (parsed.formatVersion !== SELECTION_CLIPBOARD_FORMAT_VERSION) return null;
123
+
124
+ const sections = Array.isArray(parsed.sections) ? parsed.sections : [];
125
+ const blocks = Array.isArray(parsed.blocks) ? parsed.blocks : [];
126
+ const variables = Array.isArray(parsed.variables) ? parsed.variables : [];
127
+
128
+ for (const sec of sections) {
129
+ if (!isRecord(sec) || !Array.isArray(sec.columns)) return null;
130
+ for (const col of sec.columns) {
131
+ if (!isRecord(col) || !Array.isArray(col.blocks)) return null;
132
+ for (const blk of col.blocks) {
133
+ if (!isRecord(blk) || typeof blk.type !== 'string') return null;
134
+ }
135
+ }
136
+ }
137
+ for (const blk of blocks) {
138
+ if (!isRecord(blk) || typeof blk.type !== 'string') return null;
139
+ }
140
+
141
+ return structuredClone({
142
+ kind: SELECTION_CLIPBOARD_KIND,
143
+ formatVersion: SELECTION_CLIPBOARD_FORMAT_VERSION,
144
+ variables: variables.filter(isVariableShape),
145
+ sections,
146
+ blocks,
147
+ } satisfies SelectionClipboardEnvelope);
148
+ }
149
+
69
150
  /** 导入前重生成 section / column / block id,避免与当前文档冲突。 */
70
151
  export function regenerateDocIds(doc: EmailDoc): EmailDoc {
71
152
  const next = structuredClone(doc);
@@ -73,7 +154,8 @@ export function regenerateDocIds(doc: EmailDoc): EmailDoc {
73
154
  return next;
74
155
  }
75
156
 
76
- function remapSectionIds(sec: Section): Section {
157
+ /** 重生成单个 Section(含列与块)的全部 id,用于局部粘贴。 */
158
+ export function remapSectionIds(sec: Section): Section {
77
159
  return {
78
160
  ...sec,
79
161
  id: uid('sec'),
@@ -87,3 +169,26 @@ function remapSectionIds(sec: Section): Section {
87
169
  })),
88
170
  };
89
171
  }
172
+
173
+ /** 重生成单个 Block 的 id,用于局部粘贴。 */
174
+ export function regenerateBlockId(block: Block): Block {
175
+ return { ...block, id: uid('blk') };
176
+ }
177
+
178
+ /** 扫描局部设计稿内容中引用的 `{{key}}` 变量名集合(粘贴时用于变量并集)。 */
179
+ export function collectSelectionVariableKeys(envelope: SelectionClipboardEnvelope): Set<string> {
180
+ const keys = new Set<string>();
181
+ const scan = (value: unknown) => {
182
+ const text = JSON.stringify(value) ?? '';
183
+ const re = /\{\{\s*([^}]+?)\s*\}\}/g;
184
+ let m: RegExpExecArray | null = re.exec(text);
185
+ while (m) {
186
+ const key = m[1]?.trim();
187
+ if (key) keys.add(key);
188
+ m = re.exec(text);
189
+ }
190
+ };
191
+ scan(envelope.sections);
192
+ scan(envelope.blocks);
193
+ return keys;
194
+ }
@@ -1,11 +1,11 @@
1
1
  import type { EmailDoc } from '../types';
2
- import { serializeParsedHtmlDocument } from './html';
3
2
  import {
4
3
  DYNAMIC_VARIANT_HTML_ATTR,
5
4
  getSectionDynamicVariantKey,
6
5
  isDynamicVariantSection,
7
6
  sectionMjClassName,
8
7
  } from './dynamicVariantKey';
8
+ import { serializeParsedHtmlDocument } from './html';
9
9
 
10
10
  /**
11
11
  * 编译后为带 `dynamicVariantKey` 的 Section 根节点写入 `data-dv="{key}"`,供宿主 DOM 抽取。
@@ -106,7 +106,7 @@ export function getListIndent(list: HTMLUListElement | HTMLOListElement): ListIn
106
106
  if (list.hasAttribute(LIST_INDENT_ATTR)) {
107
107
  const raw = list.getAttribute(LIST_INDENT_ATTR);
108
108
  if (raw == null || raw === 'default') return null;
109
- const n = parseInt(raw, 10);
109
+ const n = Number.parseInt(raw, 10);
110
110
  return Number.isFinite(n) ? clampListIndent(n) : null;
111
111
  }
112
112
  const fromStyle =
@@ -2,22 +2,23 @@
2
2
  export const FONT_WEIGHT_STEP_VALUES = ['300', '400', '500', '600', '700'] as const;
3
3
  export type FontWeightStep = (typeof FONT_WEIGHT_STEP_VALUES)[number];
4
4
 
5
- export const FONT_WEIGHT_STEP_OPTIONS: { label: string; value: string }[] = FONT_WEIGHT_STEP_VALUES.map(
6
- (v) => ({ label: v, value: v }),
7
- );
5
+ export const FONT_WEIGHT_STEP_OPTIONS: { label: string; value: string }[] =
6
+ FONT_WEIGHT_STEP_VALUES.map((v) => ({ label: v, value: v }));
8
7
 
9
8
  /** 将任意历史写法映射到最近一档(normal→400,bold→700 等) */
10
9
  export function normalizeFontWeightStep(raw: string | undefined | null): FontWeightStep {
11
- const s = String(raw ?? '400').trim().toLowerCase();
10
+ const s = String(raw ?? '400')
11
+ .trim()
12
+ .toLowerCase();
12
13
  if (s === '' || s === 'normal' || s === 'regular') return '400';
13
14
  if (s === 'bold' || s === 'bolder') return '700';
14
15
  if (s === 'lighter') return '300';
15
16
  const match = /^(\d+)(\.\d+)?$/.exec(s);
16
17
  if (match) {
17
- const n = parseInt(match[1], 10);
18
+ const n = Number.parseInt(match[1], 10);
18
19
  const steps = [300, 400, 500, 600, 700];
19
20
  let best: FontWeightStep = '400';
20
- let bestD = Infinity;
21
+ let bestD = Number.POSITIVE_INFINITY;
21
22
  for (const st of steps) {
22
23
  const d = Math.abs(n - st);
23
24
  if (d < bestD) {
@@ -58,9 +58,10 @@ export function prettyHtml(html: string, indent = ' '): string {
58
58
  ]);
59
59
  const PRESERVE = new Set(['pre', 'textarea', 'script', 'style']);
60
60
 
61
- const tokens = html.match(/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<![^>]*>|<\/?[^<>]+>|[^<]+/g) ?? [];
61
+ const tokens =
62
+ html.match(/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<![^>]*>|<\/?[^<>]+>|[^<]+/g) ?? [];
62
63
  let depth = 0;
63
- let preserveStack: string[] = [];
64
+ const preserveStack: string[] = [];
64
65
  const out: string[] = [];
65
66
  const pad = (n: number) => indent.repeat(Math.max(0, n));
66
67
 
@@ -3,7 +3,8 @@
3
3
  /** 空列表项 Enter 退出后插入的正文段,勿当作可合并的分隔块 */
4
4
  export const LIST_GAP_ATTR = 'data-sm-list-gap';
5
5
 
6
- export function findListItem(node: Node | null, root: HTMLElement): HTMLLIElement | null {
6
+ export function findListItem(start: Node | null, root: HTMLElement): HTMLLIElement | null {
7
+ let node = start;
7
8
  while (node && node !== root) {
8
9
  if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === 'LI') {
9
10
  return node as HTMLLIElement;
@@ -13,9 +14,7 @@ export function findListItem(node: Node | null, root: HTMLElement): HTMLLIElemen
13
14
  return null;
14
15
  }
15
16
 
16
- export function findListRoot(
17
- li: HTMLLIElement,
18
- ): HTMLUListElement | HTMLOListElement | null {
17
+ export function findListRoot(li: HTMLLIElement): HTMLUListElement | HTMLOListElement | null {
19
18
  const parent = li.parentElement;
20
19
  if (!parent) return null;
21
20
  if (parent.tagName === 'UL' || parent.tagName === 'OL') {
@@ -146,9 +145,9 @@ export function setCaretByTextOffset(container: HTMLElement, offset: number): vo
146
145
  const target = Math.max(0, offset);
147
146
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
148
147
  let remaining = target;
149
- let textNode: Text | null = null;
148
+ let textNode = walker.nextNode() as Text | null;
150
149
 
151
- while ((textNode = walker.nextNode() as Text | null)) {
150
+ while (textNode) {
152
151
  const data = textNode.data ?? '';
153
152
  const len = visibleTextLength(data);
154
153
  if (remaining <= len) {
@@ -171,6 +170,7 @@ export function setCaretByTextOffset(container: HTMLElement, offset: number): vo
171
170
  return;
172
171
  }
173
172
  remaining -= len;
173
+ textNode = walker.nextNode() as Text | null;
174
174
  }
175
175
 
176
176
  placeCaretAtEndOfElement(container);
@@ -226,17 +226,12 @@ export function insertCaretMarkerAtRangeEnd(range: Range): HTMLSpanElement | nul
226
226
 
227
227
  export function removeSelectionBoundaryMarkers(root: HTMLElement): void {
228
228
  root
229
- .querySelectorAll(
230
- `span[${RANGE_START_MARKER_ATTR}="1"], span[${CARET_MARKER_ATTR}="1"]`,
231
- )
229
+ .querySelectorAll(`span[${RANGE_START_MARKER_ATTR}="1"], span[${CARET_MARKER_ATTR}="1"]`)
232
230
  .forEach((m) => m.remove());
233
231
  }
234
232
 
235
233
  /** 选区末尾在容器内的可见文本偏移 */
236
- export function getCaretTextOffsetAtRangeEnd(
237
- container: HTMLElement,
238
- range: Range,
239
- ): number {
234
+ export function getCaretTextOffsetAtRangeEnd(container: HTMLElement, range: Range): number {
240
235
  const atEnd = range.cloneRange();
241
236
  atEnd.collapse(false);
242
237
  return getCaretTextOffset(container, atEnd);
@@ -272,18 +267,14 @@ function listInSelectionSpan(list: Element, start: Node, end: Node): boolean {
272
267
  const { anchor, far } = normalizeSelectionSpan(start, end);
273
268
  const afterAnchor =
274
269
  (list.compareDocumentPosition(anchor) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
275
- const beforeFar =
276
- (list.compareDocumentPosition(far) & Node.DOCUMENT_POSITION_PRECEDING) !== 0;
270
+ const beforeFar = (list.compareDocumentPosition(far) & Node.DOCUMENT_POSITION_PRECEDING) !== 0;
277
271
  return afterAnchor && beforeFar;
278
272
  }
279
273
 
280
274
  function lastListInDocumentOrder(lists: ListElement[]): ListElement | null {
281
275
  let best: ListElement | null = null;
282
276
  for (const list of lists) {
283
- if (
284
- !best ||
285
- (best.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
286
- ) {
277
+ if (!best || (best.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0) {
287
278
  best = list;
288
279
  }
289
280
  }
@@ -291,9 +282,7 @@ function lastListInDocumentOrder(lists: ListElement[]): ListElement | null {
291
282
  }
292
283
 
293
284
  function placeCaretAtEndOfList(list: ListElement): boolean {
294
- const items = Array.from(list.children).filter(
295
- (c): c is HTMLLIElement => c.tagName === 'LI',
296
- );
285
+ const items = Array.from(list.children).filter((c): c is HTMLLIElement => c.tagName === 'LI');
297
286
  const lastLi = items[items.length - 1];
298
287
  if (!lastLi) return false;
299
288
  placeCaretAtEndOfElement(lastLi);
@@ -353,9 +342,7 @@ export function exitListFromEmptyItem(li: HTMLLIElement): void {
353
342
  const list = findListRoot(li);
354
343
  if (!list) return;
355
344
 
356
- const items = Array.from(list.children).filter(
357
- (c): c is HTMLLIElement => c.tagName === 'LI',
358
- );
345
+ const items = Array.from(list.children).filter((c): c is HTMLLIElement => c.tagName === 'LI');
359
346
  const index = items.indexOf(li);
360
347
  if (index === -1) return;
361
348
 
@@ -571,10 +558,7 @@ function sortByDocumentOrder<T extends Node>(nodes: T[]): T[] {
571
558
  }
572
559
 
573
560
  /** 选区触及的列表项(块级,按文档顺序) */
574
- export function getListItemsInRange(
575
- range: Range,
576
- root: HTMLElement,
577
- ): HTMLLIElement[] {
561
+ export function getListItemsInRange(range: Range, root: HTMLElement): HTMLLIElement[] {
578
562
  const items: HTMLLIElement[] = [];
579
563
  for (const node of root.querySelectorAll('li')) {
580
564
  if (!(node instanceof HTMLLIElement)) continue;
@@ -614,9 +598,7 @@ function appendUnwrappedItems(frag: DocumentFragment, items: HTMLLIElement[]): v
614
598
  }
615
599
  }
616
600
 
617
- type ListSegmentMiddle =
618
- | 'unwrap'
619
- | { tag: 'ul' | 'ol' };
601
+ type ListSegmentMiddle = 'unwrap' | { tag: 'ul' | 'ol' };
620
602
 
621
603
  /** 将列表拆成前/中/后三段:中段改类型或取消列表 */
622
604
  function replaceListWithSegments(
@@ -695,8 +677,7 @@ export function applyListCommandForSelection(
695
677
  );
696
678
  const selectedSet = new Set(selected);
697
679
  const allSelected =
698
- selected.length === allItems.length &&
699
- allItems.every((li) => selectedSet.has(li));
680
+ selected.length === allItems.length && allItems.every((li) => selectedSet.has(li));
700
681
 
701
682
  if (allSelected) {
702
683
  applyListTypeToWholeList(list, wantUl);
@@ -761,7 +742,10 @@ export function convertListTag(
761
742
  return next;
762
743
  }
763
744
 
764
- export function detectListFormats(node: Node | null, root: HTMLElement): {
745
+ export function detectListFormats(
746
+ node: Node | null,
747
+ root: HTMLElement,
748
+ ): {
765
749
  unorderedList: boolean;
766
750
  orderedList: boolean;
767
751
  } {
@@ -11,9 +11,9 @@
11
11
  */
12
12
 
13
13
  /** 变量占位(发送时替换),如 {{couponLink}} */
14
- const VARIABLE_PATTERN = /\{\{[^{}]*\}\}/
14
+ const VARIABLE_PATTERN = /\{\{[^{}]*\}\}/;
15
15
  /** 放行协议;其余一律不放行,兼顾安全(屏蔽 javascript: / data: 等) */
16
- const ALLOWED_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:', 'sms:']
16
+ const ALLOWED_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:', 'sms:'];
17
17
 
18
18
  export function isRenderableHref(raw: unknown): boolean {
19
19
  const href = String(raw ?? '').trim();
@@ -41,10 +41,7 @@ export function resolveHref(raw: unknown): string {
41
41
  }
42
42
 
43
43
  /** 右栏链接字段默认校验:空值放行(允许暂不填),填了但不可跳转则给提示 */
44
- export function invalidHrefMessage(
45
- raw: unknown,
46
- t: (key: string) => string,
47
- ): string | null {
44
+ export function invalidHrefMessage(raw: unknown, t: (key: string) => string): string | null {
48
45
  const href = String(raw ?? '').trim();
49
46
  if (!href || isRenderableHref(href)) return null;
50
47
  return t('rightPanel.block.invalidLink');
@@ -17,7 +17,7 @@ export interface MjRawCellTypography {
17
17
  export function mjRawCellTypographyFromStyles(s: GlobalStyles): MjRawCellTypography {
18
18
  return {
19
19
  fontSize: (s.fontSize || '16px').trim(),
20
- lineHeight: (s.lineHeight || '1.25').trim(),
20
+ lineHeight: (s.lineHeight || '1.5').trim(),
21
21
  color: (s.color || '#433f3f').trim(),
22
22
  fontFamily: (s.fontFamily || 'sans-serif').trim(),
23
23
  fontWeight: normalizeFontWeightStep(s.fontWeight),
@@ -172,7 +172,7 @@ export function extractPreviewHtmlFromLockedMjml(lockedMjml: string): string | n
172
172
 
173
173
  const mjRaw = raw.match(/^<mj-raw\b[^>]*>([\s\S]*)<\/mj-raw\s*>$/i);
174
174
  if (mjRaw) {
175
- let body = mjRaw[1].trim();
175
+ const body = mjRaw[1].trim();
176
176
  if (/^\s*<table\b/i.test(body)) {
177
177
  const tdInner = innerFromPresentationTable(body);
178
178
  if (tdInner != null) return tdInner;
@@ -15,9 +15,10 @@ export function getCodeEditorModalDefaultSize(): CodeEditorModalSize {
15
15
  }
16
16
 
17
17
  /** 代码编辑弹框最大化时的视口占比 */
18
- export function getCodeEditorModalMaximizedSize(
19
- opts?: { maxWidthRatio?: number; maxHeightRatio?: number },
20
- ): CodeEditorModalSize {
18
+ export function getCodeEditorModalMaximizedSize(opts?: {
19
+ maxWidthRatio?: number;
20
+ maxHeightRatio?: number;
21
+ }): CodeEditorModalSize {
21
22
  const maxWidthRatio = opts?.maxWidthRatio ?? DEFAULT_MAX_WIDTH_RATIO;
22
23
  const maxHeightRatio = opts?.maxHeightRatio ?? DEFAULT_MAX_HEIGHT_RATIO;
23
24
  return {
@@ -1,6 +1,8 @@
1
1
  /** 是否仅为进入编辑时注入的占位 <br>(用户未改动) */
2
2
  export function isRichHtmlEditorSeedOnly(html: string): boolean {
3
- const s = String(html ?? '').trim().toLowerCase();
3
+ const s = String(html ?? '')
4
+ .trim()
5
+ .toLowerCase();
4
6
  return s === '<br>' || s === '<br/>' || s === '<br />';
5
7
  }
6
8
 
@@ -14,7 +14,9 @@ export function variablePlaceholder(key: string): string {
14
14
  }
15
15
 
16
16
  /** 无显式 sample 时保留 Mustache 占位符;仅当宿主传入 sample 才用于可选的示例替换。 */
17
- export function defaultVariableSample(v: Pick<Variable, 'key' | 'label' | 'kind' | 'sample'>): string {
17
+ export function defaultVariableSample(
18
+ v: Pick<Variable, 'key' | 'label' | 'kind' | 'sample'>,
19
+ ): string {
18
20
  if (v.sample != null && String(v.sample).trim() !== '') return String(v.sample);
19
21
  const key = v.key?.trim() || tokenToVariableKey(String(v.key));
20
22
  return variablePlaceholder(key);
@@ -34,10 +36,7 @@ export function normalizeVariable(v: Variable): Variable {
34
36
 
35
37
  export function buildLinkVariableHtml(token: string, linkColor: string): string {
36
38
  const safeHref = token.replace(/"/g, '&quot;');
37
- const safeText = token
38
- .replace(/&/g, '&amp;')
39
- .replace(/</g, '&lt;')
40
- .replace(/>/g, '&gt;');
39
+ const safeText = token.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
41
40
  return `<a href="${safeHref}" style="color:${linkColor} !important;text-decoration:underline;">${safeText}</a>`;
42
41
  }
43
42
 
@@ -1,5 +1,5 @@
1
- import { defineConfig } from 'vite';
2
1
  import { resolve } from 'node:path';
2
+ import { defineConfig } from 'vite';
3
3
  import dts from 'vite-plugin-dts';
4
4
 
5
5
  export default defineConfig({
@@ -7,10 +7,27 @@
7
7
  <style>
8
8
  html, body, #app { height: 100%; margin: 0; padding: 0; }
9
9
  body { font: 14px -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif; }
10
+ #locale-switch {
11
+ position: fixed;
12
+ right: 16px;
13
+ bottom: 16px;
14
+ z-index: 9999;
15
+ height: 32px;
16
+ padding: 0 14px;
17
+ border: 1px solid rgba(0, 0, 0, 0.12);
18
+ border-radius: 16px;
19
+ background: #fff;
20
+ color: #333;
21
+ font: 600 12px/30px -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
22
+ cursor: pointer;
23
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
24
+ }
25
+ #locale-switch:hover { background: #f5f5f5; }
10
26
  </style>
11
27
  </head>
12
28
  <body>
13
29
  <div id="app"></div>
30
+ <button id="locale-switch" type="button"></button>
14
31
  <script type="module" src="/src/main.ts"></script>
15
32
  </body>
16
33
  </html>