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.
- package/README.md +37 -4
- package/package.json +1 -1
- package/packages/blocks/dist/index.js +275 -263
- package/packages/blocks/dist/index.js.map +1 -1
- package/packages/blocks/package.json +1 -3
- package/packages/blocks/src/button.ts +1 -1
- package/packages/blocks/src/hero.ts +6 -3
- package/packages/blocks/src/i18n.ts +10 -11
- package/packages/blocks/src/image.ts +2 -1
- package/packages/blocks/src/index.ts +5 -3
- package/packages/blocks/src/social.ts +3 -3
- package/packages/blocks/src/socialGroup.ts +4 -4
- package/packages/blocks/src/socialShared.ts +23 -3
- package/packages/blocks/src/text.ts +8 -4
- package/packages/blocks/vite.config.ts +1 -1
- package/packages/core/dist/index.d.ts +96 -1
- package/packages/core/dist/index.js +4349 -3594
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/dist/style.css +1 -1
- package/packages/core/package.json +1 -3
- package/packages/core/src/editor/BlockCodeModal.ts +6 -7
- package/packages/core/src/editor/Canvas.ts +559 -21
- package/packages/core/src/editor/ColorPickerPopover.ts +2 -2
- package/packages/core/src/editor/Editor.ts +381 -41
- package/packages/core/src/editor/ExportModal.ts +7 -9
- package/packages/core/src/editor/FocusBreadcrumb.ts +3 -3
- package/packages/core/src/editor/ImportDocModal.ts +50 -7
- package/packages/core/src/editor/InlineEditor.ts +43 -39
- package/packages/core/src/editor/LeftPanel.ts +2 -4
- package/packages/core/src/editor/Modal.ts +1 -1
- package/packages/core/src/editor/PreviewModal.ts +3 -8
- package/packages/core/src/editor/RichTextToolbar.ts +26 -15
- package/packages/core/src/editor/RightPanel.ts +142 -126
- package/packages/core/src/editor/SourceView.ts +5 -3
- package/packages/core/src/editor/Topbar.ts +79 -34
- package/packages/core/src/editor/VariablePickerPanel.ts +3 -5
- package/packages/core/src/editor/imageAssets.ts +1 -1
- package/packages/core/src/editor/styles.css +258 -86
- package/packages/core/src/i18n/index.ts +139 -41
- package/packages/core/src/index.ts +11 -1
- package/packages/core/src/renderer/index.ts +3 -6
- package/packages/core/src/renderer/mjml.ts +5 -10
- package/packages/core/src/store/store.ts +2 -6
- package/packages/core/src/types.ts +3 -3
- package/packages/core/src/utils/accentColor.ts +3 -3
- package/packages/core/src/utils/docClipboard.ts +107 -2
- package/packages/core/src/utils/dynamicVariantHtml.ts +1 -1
- package/packages/core/src/utils/emailListStyles.ts +1 -1
- package/packages/core/src/utils/fontWeightSteps.ts +7 -6
- package/packages/core/src/utils/html.ts +3 -2
- package/packages/core/src/utils/inlineListEditing.ts +19 -35
- package/packages/core/src/utils/link.ts +3 -6
- package/packages/core/src/utils/lockedMjml.ts +2 -2
- package/packages/core/src/utils/modalSize.ts +4 -3
- package/packages/core/src/utils/richHtmlEmpty.ts +3 -1
- package/packages/core/src/variables/index.ts +4 -5
- package/packages/core/vite.config.ts +1 -1
- package/playground/vanilla/index.html +17 -0
- package/playground/vanilla/src/main.ts +152 -123
- 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
|
-
|
|
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 }[] =
|
|
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')
|
|
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 =
|
|
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 =
|
|
61
|
+
const tokens =
|
|
62
|
+
html.match(/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<![^>]*>|<\/?[^<>]+>|[^<]+/g) ?? [];
|
|
62
63
|
let depth = 0;
|
|
63
|
-
|
|
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(
|
|
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
|
|
148
|
+
let textNode = walker.nextNode() as Text | null;
|
|
150
149
|
|
|
151
|
-
while (
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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 ?? '')
|
|
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(
|
|
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, '"');
|
|
37
|
-
const safeText = token
|
|
38
|
-
.replace(/&/g, '&')
|
|
39
|
-
.replace(/</g, '<')
|
|
40
|
-
.replace(/>/g, '>');
|
|
39
|
+
const safeText = token.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
41
40
|
return `<a href="${safeHref}" style="color:${linkColor} !important;text-decoration:underline;">${safeText}</a>`;
|
|
42
41
|
}
|
|
43
42
|
|
|
@@ -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>
|