mail-editor-pancake 0.2.4 → 0.2.7

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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * 富文本内联样式的「净化」工具。
3
+ *
4
+ * 背景:给一段文字改字号时,编辑器是「包一层 `<span style="font-size:B">`」。
5
+ * 如果这段文字里已经有 `<span style="font-size:A">`,内层 A 会继续覆盖外层 B
6
+ * ——表现为「选了更大的范围改字号,被包含的旧字号却不变」。
7
+ *
8
+ * 因此应用内联样式前需要先把选区内**同属性**的旧声明清掉:
9
+ * - 只留其他属性(如 color),`font-size` 被新值接管;
10
+ * - 清完变成空壳(无 style、无任何其他属性)的 span / font 直接拆掉,
11
+ * 避免出现 `<span style="">text</span>` 这种无意义标签。
12
+ *
13
+ * 部分重叠场景无需特殊处理:只有被选区取走的那部分内容进入 fragment,
14
+ * 留在原处的文本仍受旧元素管辖,旧字号自然保留。
15
+ */
16
+
17
+ /** 允许被拆掉的「纯样式壳」标签(语义标签如 a / strong / li 不在内) */
18
+ const UNWRAPPABLE_TAGS = new Set(['span', 'font']);
19
+ const UNWRAPPABLE_SELECTOR = 'span, font';
20
+
21
+ function declRegexp(prop: string): RegExp {
22
+ const escaped = prop.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
23
+ // 以 `;` 或串首起头,避免误伤 `background-color` 之于 `color`
24
+ return new RegExp(`(?:^|;)\\s*${escaped}\\s*:\\s*[^;]*`, 'i');
25
+ }
26
+
27
+ /** 从 style 串中删掉某条声明(保留其余声明与分隔符) */
28
+ export function removeStyleDecl(styleAttr: string, prop: string): string {
29
+ const next = styleAttr
30
+ .replace(declRegexp(prop), '')
31
+ .replace(/;{2,}/g, ';')
32
+ .replace(/^\s*;\s*/, '')
33
+ .trim();
34
+ return next === ';' ? '' : next;
35
+ }
36
+
37
+ /** 删掉元素 style 里的某条声明;样式清空时移除 style 属性本身。返回是否有变化。 */
38
+ export function stripInlineStyleDecl(el: Element, prop: string): boolean {
39
+ if (!el.hasAttribute('style')) return false;
40
+ const before = el.getAttribute('style') ?? '';
41
+ const after = removeStyleDecl(before, prop);
42
+ if (after === before) return false;
43
+ if (after) el.setAttribute('style', after);
44
+ else el.removeAttribute('style');
45
+ return true;
46
+ }
47
+
48
+ /** 纯样式壳:span/font 且一个属性都不剩 */
49
+ export function isEmptyInlineShell(el: Element): boolean {
50
+ return UNWRAPPABLE_TAGS.has(el.tagName.toLowerCase()) && !el.hasAttributes();
51
+ }
52
+
53
+ /**
54
+ * 无内容壳:span/font 内只剩空文本节点(textContent 为空,连零宽字符都没有),
55
+ * 也没有 br / img 等占位元素——`extractContents` 在边界 split 时常留下这种残壳,
56
+ * 它仍有 style 属性但没有任何可渲染内容,留着只会让 innerHTML 变脏。
57
+ * typing 占位 span 含零宽字符,textContent 非空,不会误伤。
58
+ */
59
+ export function isContentlessShell(el: Element): boolean {
60
+ if (!UNWRAPPABLE_TAGS.has(el.tagName.toLowerCase())) return false;
61
+ if (el.hasAttribute('data-sm-typing')) return false;
62
+ if (el.querySelector('br, img')) return false;
63
+ const text = (el.textContent ?? '').replace(/​/g, '');
64
+ return text.length === 0;
65
+ }
66
+
67
+ /** 把元素自身从 DOM 里拆掉,子节点上提到原位置 */
68
+ export function unwrapElement(el: Element): void {
69
+ const parent = el.parentNode;
70
+ if (!parent) return;
71
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
72
+ parent.removeChild(el);
73
+ }
74
+
75
+ /**
76
+ * 在片段 / 子树内清除某条 inline 声明,并把因此变成空壳的 span / font 拆掉。
77
+ * 倒序遍历:先处理内层,父元素变空壳后可以直接上提,不会漏拆。
78
+ */
79
+ export function purgeInlineStyleDecl(root: ParentNode, prop: string): void {
80
+ const targets: Element[] = [];
81
+ if (root.nodeType === 1) targets.push(root as Element);
82
+ targets.push(...Array.from(root.querySelectorAll('[style]')));
83
+
84
+ for (let i = targets.length - 1; i >= 0; i--) {
85
+ const el = targets[i];
86
+ if (!el.parentNode) continue;
87
+ stripInlineStyleDecl(el, prop);
88
+ if (isEmptyInlineShell(el)) unwrapElement(el);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * 旧 `<font size="1..7">` → px。必须用浏览器实际渲染 legacy 档位的那张表
94
+ * (与 HTML 规范建议值一致:1=10, 2=13, 3=16, 4=18, 5=24, 6=32, 7=48),
95
+ * 否则编辑器里看到的字号与提交进邮件的字号不一致(如 size=5 显示 24px 却存成 20px)。
96
+ */
97
+ const LEGACY_FONT_SIZE_MAP: Record<string, string> = {
98
+ '1': '10px',
99
+ '2': '13px',
100
+ '3': '16px',
101
+ '4': '18px',
102
+ '5': '24px',
103
+ '6': '32px',
104
+ '7': '48px',
105
+ };
106
+
107
+ export function fontSizeFromLegacy(size: string): string {
108
+ return LEGACY_FONT_SIZE_MAP[size] ?? size;
109
+ }
110
+
111
+ /**
112
+ * 把子树内的 `<font color/size/face>` 统一转成 `<span style>`。
113
+ *
114
+ * 背景:`<font size>` 的字号在 **属性** 上、不在 style 上,
115
+ * `purgeInlineStyleDecl` 按 `[style]` 选择器抓取会漏掉它,
116
+ * 于是选中含 `<font size>` 的范围改字号时外层新值盖不过 `<font>` 的 UA 字号。
117
+ * 因此 apply 内联样式前需先把 `<font>` 转成 span,让字号落到 style 上,
118
+ * purge 才能命中。sanitize 提交前也复用同一个转换。
119
+ *
120
+ * 只做「标签转换」,不做属性清理:转出的 span 若仍带 color/face 不会空壳、不会 unwrap。
121
+ */
122
+ export function convertFontTags(root: ParentNode): void {
123
+ const fonts = Array.from(root.querySelectorAll('font'));
124
+ for (let i = fonts.length - 1; i >= 0; i--) {
125
+ const el = fonts[i];
126
+ const parent = el.parentNode;
127
+ if (!parent) continue;
128
+ const span = document.createElement('span');
129
+ const color = el.getAttribute('color');
130
+ const size = el.getAttribute('size');
131
+ const face = el.getAttribute('face');
132
+ const styles: string[] = [el.getAttribute('style') ?? ''];
133
+ if (color) styles.push(`color:${color}`);
134
+ if (size) styles.push(`font-size:${fontSizeFromLegacy(size)}`);
135
+ if (face) styles.push(`font-family:${face}`);
136
+ const cleaned = styles.filter((s) => s.trim()).join(';');
137
+ if (cleaned) span.setAttribute('style', cleaned);
138
+ while (el.firstChild) span.appendChild(el.firstChild);
139
+ parent.replaceChild(span, el);
140
+ }
141
+ }
142
+
143
+ /** 提交前清理:拆掉纯样式空壳 + 无内容残壳 */
144
+ export function unwrapEmptyInlineShells(root: ParentNode): void {
145
+ const list = Array.from(root.querySelectorAll(UNWRAPPABLE_SELECTOR));
146
+ for (let i = list.length - 1; i >= 0; i--) {
147
+ const el = list[i];
148
+ if (el.parentNode && (isEmptyInlineShell(el) || isContentlessShell(el))) unwrapElement(el);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 合并 style/class 完全相同的父子 span:反复改字号/字重会套出多层同义 span,
154
+ * 渲染结果一样但 HTML 变胖(逼近 Gmail 102KB 截断)。合并后内层子节点上提。
155
+ */
156
+ export function mergeRedundantSpans(root: ParentNode): void {
157
+ for (let pass = 0; pass < 5; pass++) {
158
+ let changed = false;
159
+ for (const el of Array.from(root.querySelectorAll('span'))) {
160
+ const parent = el.parentElement;
161
+ if (!parent || parent.tagName.toLowerCase() !== 'span') continue;
162
+ if (spanIdentity(parent) !== spanIdentity(el)) continue;
163
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
164
+ el.remove();
165
+ changed = true;
166
+ }
167
+ if (!changed) break;
168
+ }
169
+ }
170
+
171
+ /** span 的样式身份:class + 规范化后的 style,用于判断是否可合并 */
172
+ function spanIdentity(el: Element): string {
173
+ const style = (el.getAttribute('style') ?? '')
174
+ .replace(/\s+/g, ' ')
175
+ .trim()
176
+ .toLowerCase()
177
+ .replace(/;+$/, '');
178
+ return `${(el.getAttribute('class') ?? '').trim()} ${style}`;
179
+ }
180
+
181
+ export function firstTextNode(root: Node): Text | null {
182
+ if (root.nodeType === Node.TEXT_NODE) return root as Text;
183
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
184
+ return walker.nextNode() as Text | null;
185
+ }
186
+
187
+ export function lastTextNode(root: Node): Text | null {
188
+ if (root.nodeType === Node.TEXT_NODE) return root as Text;
189
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
190
+ let last: Text | null = null;
191
+ let n = walker.nextNode();
192
+ while (n) {
193
+ last = n as Text;
194
+ n = walker.nextNode();
195
+ }
196
+ return last;
197
+ }