mail-editor-pancake 0.0.7 → 0.0.8

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.
@@ -9,6 +9,14 @@
9
9
  * mode=html 时提交原始 innerHTML(用于 mj-raw 等),不挂富文本浮动条。
10
10
  * - 选区变化通过 `onSelectionChange` 实时回调,便于浮动工具条同步按钮状态/位置。
11
11
  */
12
+ import { normalizeFontWeightStep } from '../utils/fontWeightSteps';
13
+ import {
14
+ richTextExecCommand,
15
+ richTextQueryCommandState,
16
+ richTextQueryCommandValue,
17
+ } from '../utils/richTextCommand';
18
+ import { isColorPickerOpen } from './ColorPickerPopover';
19
+
12
20
  export interface InlineEditorOptions {
13
21
  el: HTMLElement;
14
22
  initialValue: string;
@@ -36,6 +44,8 @@ export interface SelectionState {
36
44
  foreColor: string | null;
37
45
  fontSize: string | null;
38
46
  fontName: string | null;
47
+ fontWeight: string | null;
48
+ backColor: string | null;
39
49
  };
40
50
  }
41
51
 
@@ -55,6 +65,9 @@ export class InlineEditor {
55
65
  private savedRange: Range | null = null;
56
66
  /** 富文本工具条控件会抢走焦点,此时 getSelection 已不在编辑区内;保留上一帧状态避免工具条整栏收起。 */
57
67
  private lastSelectionState: SelectionState | null = null;
68
+ /** hiliteColor 在输入前不写 DOM;记录「待输入背景色 + 锚点」以便 bar 即时同步,移开光标后清除 */
69
+ private pendingHiliteColor: string | null = null;
70
+ private pendingHiliteAnchor: string | null = null;
58
71
 
59
72
  constructor(opts: InlineEditorOptions) {
60
73
  this.opts = opts;
@@ -112,10 +125,19 @@ export class InlineEditor {
112
125
  exec(command: string, value?: string) {
113
126
  this._ensureFocus();
114
127
  this._restoreSelection();
128
+ const sel = window.getSelection();
129
+ let hiliteAnchor: string | null = null;
130
+ if (command === 'hiliteColor' && sel?.rangeCount) {
131
+ hiliteAnchor = selectionAnchorKey(sel.getRangeAt(0));
132
+ }
115
133
  // 列表:Chrome 等在 styleWithCSS=true 下 insertOrderedList / insertUnorderedList 常失灵,需退回 HTML 列表标签
116
134
  const isListCmd = command === 'insertOrderedList' || command === 'insertUnorderedList';
117
- document.execCommand('styleWithCSS', false, isListCmd ? ('false' as any) : ('true' as any));
118
- document.execCommand(command, false, value);
135
+ richTextExecCommand('styleWithCSS', false, isListCmd ? 'false' : 'true');
136
+ richTextExecCommand(command, false, value);
137
+ if (command === 'hiliteColor') {
138
+ this.pendingHiliteColor = value ?? null;
139
+ this.pendingHiliteAnchor = hiliteAnchor;
140
+ }
119
141
  // 命令执行后选区可能已经变化(例如插入链接会扩展),刷新存档
120
142
  this.saveSelection();
121
143
  this._emitSelection();
@@ -130,7 +152,7 @@ export class InlineEditor {
130
152
  this._restoreSelection();
131
153
  const trimmed = url.trim();
132
154
  if (!trimmed) {
133
- document.execCommand('unlink');
155
+ richTextExecCommand('unlink');
134
156
  this.saveSelection();
135
157
  this._emitSelection();
136
158
  return;
@@ -143,8 +165,8 @@ export class InlineEditor {
143
165
  anchor.setAttribute('href', safe);
144
166
  if (!anchor.getAttribute('rel')) anchor.setAttribute('rel', 'noopener');
145
167
  } else {
146
- document.execCommand('styleWithCSS', false, 'true' as any);
147
- document.execCommand('createLink', false, safe);
168
+ richTextExecCommand('styleWithCSS', false, 'true');
169
+ richTextExecCommand('createLink', false, safe);
148
170
  }
149
171
  this.saveSelection();
150
172
  this._emitSelection();
@@ -154,7 +176,7 @@ export class InlineEditor {
154
176
  insertText(text: string) {
155
177
  this._ensureFocus();
156
178
  this._restoreSelection();
157
- document.execCommand('insertText', false, text);
179
+ richTextExecCommand('insertText', false, text);
158
180
  this.saveSelection();
159
181
  }
160
182
 
@@ -164,6 +186,52 @@ export class InlineEditor {
164
186
  this._restoreSelection();
165
187
  }
166
188
 
189
+ /** 字号:走原生 fontSize 命令(styleWithCSS 下浏览器会写 span),与 foreColor 同类 */
190
+ applyFontSize(px: string) {
191
+ this.exec('fontSize', pxToLegacyFontSize(px));
192
+ }
193
+
194
+ /**
195
+ * 字重:浏览器只有 bold 命令,没有 font-weight:500/600 的原生 API,需包 span。
196
+ * 有选区包裹内容;折叠光标插入 typing span,后续输入沿用。
197
+ */
198
+ applyFontWeight(weight: string) {
199
+ this._ensureFocus();
200
+ this._restoreSelection();
201
+ const sel = window.getSelection();
202
+ if (!sel || sel.rangeCount === 0) return;
203
+ const range = sel.getRangeAt(0);
204
+ if (!this.el.contains(range.startContainer) || !this.el.contains(range.endContainer)) return;
205
+
206
+ if (!range.collapsed) {
207
+ const span = document.createElement('span');
208
+ span.setAttribute('style', `font-weight:${weight};`);
209
+ try {
210
+ span.appendChild(range.extractContents());
211
+ range.insertNode(span);
212
+ const newRange = document.createRange();
213
+ newRange.selectNodeContents(span);
214
+ sel.removeAllRanges();
215
+ sel.addRange(newRange);
216
+ } catch {
217
+ return;
218
+ }
219
+ } else {
220
+ const span = document.createElement('span');
221
+ span.setAttribute('style', `font-weight:${weight};`);
222
+ span.appendChild(document.createTextNode('\u200b'));
223
+ range.insertNode(span);
224
+ const caret = document.createRange();
225
+ caret.setStart(span.firstChild!, 1);
226
+ caret.collapse(true);
227
+ sel.removeAllRanges();
228
+ sel.addRange(caret);
229
+ }
230
+
231
+ this.saveSelection();
232
+ this._emitSelection();
233
+ }
234
+
167
235
  /** 内联编辑根节点(如 `.sm-text-content`)的盒模型矩形,供富文本条贴在正文区域外沿定位 */
168
236
  getAnchorRect(): DOMRect {
169
237
  return this.el.getBoundingClientRect();
@@ -196,6 +264,18 @@ export class InlineEditor {
196
264
  placeCaretAtEnd(el);
197
265
  });
198
266
 
267
+ let imeComposing = false;
268
+ /** 部分浏览器在 compositionend 之后才派发 Enter keydown,需吞掉紧随其后的这一次 */
269
+ let suppressNextEnter = false;
270
+
271
+ const onCompositionStart = () => {
272
+ imeComposing = true;
273
+ };
274
+ const onCompositionEnd = () => {
275
+ imeComposing = false;
276
+ suppressNextEnter = true;
277
+ };
278
+
199
279
  const onKeyDown = (e: KeyboardEvent) => {
200
280
  if (e.key === 'Escape') {
201
281
  e.preventDefault();
@@ -203,6 +283,12 @@ export class InlineEditor {
203
283
  return;
204
284
  }
205
285
  if (e.key === 'Enter') {
286
+ // 拼音等 IME 用 Enter 上屏:不可 preventDefault / insertLineBreak
287
+ if (e.isComposing || imeComposing || e.keyCode === 229) return;
288
+ if (suppressNextEnter) {
289
+ suppressNextEnter = false;
290
+ return;
291
+ }
206
292
  if (!multiline) {
207
293
  e.preventDefault();
208
294
  this.commit();
@@ -210,16 +296,26 @@ export class InlineEditor {
210
296
  }
211
297
  // 多行下,回车默认会插入 <div> / <br>。强制 <br>,避免出现各种奇怪嵌套。
212
298
  e.preventDefault();
213
- document.execCommand('insertLineBreak');
299
+ richTextExecCommand('insertLineBreak');
214
300
  }
215
301
  };
216
- const onBlur = () => {
302
+ const onBlur = (e: FocusEvent) => {
217
303
  // 给点钟摆余地,避免点击工具条按钮时立刻提交
218
304
  setTimeout(() => {
219
305
  if (!this.alive) return;
220
- const active = document.activeElement;
306
+ const active = document.activeElement as HTMLElement | null;
221
307
  if (active && (active === el || el.contains(active))) return;
222
- if (active && (active as HTMLElement).closest?.('.sm-floating-toolbar')) return;
308
+ if (isColorPickerOpen()) return;
309
+ if (active?.closest?.('.sm-floating-toolbar, .sm-color-picker, .sm-color-picker-layer')) return;
310
+ // 从左栏拖入 / 画布内 Sortable 排序:mousedown 会先 blur,若在此时提交会触发画布重渲染并拆掉 drop 目标
311
+ if (active?.closest?.('.sm-panel--left')) return;
312
+ if (active?.closest?.('.sm-block__handle, .sm-section__handle')) return;
313
+ const related = e.relatedTarget as HTMLElement | null;
314
+ if (related?.closest?.('.sm-panel--left')) return;
315
+ if (related?.closest?.('.sm-block-card')) return;
316
+ if (related?.closest?.('.sm-block__handle, .sm-section__handle')) return;
317
+ if (related?.closest?.('.sm-color-picker, .sm-color-picker-layer')) return;
318
+ if (document.querySelector('.sm-root .sm-drag, .sm-root .sm-chosen')) return;
223
319
  this.commit();
224
320
  }, 0);
225
321
  };
@@ -228,10 +324,11 @@ export class InlineEditor {
228
324
  e.preventDefault();
229
325
  const text = e.clipboardData?.getData('text/plain') ?? '';
230
326
  const safe = multiline ? text : text.replace(/\r?\n/g, ' ');
231
- document.execCommand('insertText', false, safe);
327
+ richTextExecCommand('insertText', false, safe);
232
328
  };
233
329
  const onSelChange = () => {
234
330
  if (!this.alive) return;
331
+ this._syncPendingHiliteAnchor();
235
332
  this.saveSelection();
236
333
  this._emitSelection();
237
334
  };
@@ -245,14 +342,7 @@ export class InlineEditor {
245
342
  return a;
246
343
  };
247
344
 
248
- /** Chrome:contenteditable 里的 <a href> 仍会在 mousedown/click 时导航;阻止后用手动 caret 对齐点击处 */
249
- const onLinkMouseDown = (e: MouseEvent) => {
250
- if ((mode !== 'rich' && mode !== 'html') || e.button !== 0) return;
251
- if (!linkInsideEditing(e.target)) return;
252
- e.preventDefault();
253
- placeCaretAtClientPoint(el, e.clientX, e.clientY);
254
- };
255
-
345
+ /** Chrome:contenteditable 里的 <a href> 仍会在 click 时导航;mousedown 不拦截以保留拖选 */
256
346
  const stopLinkActivate = (e: MouseEvent) => {
257
347
  if (mode !== 'rich' && mode !== 'html') return;
258
348
  if (!linkInsideEditing(e.target)) return;
@@ -260,19 +350,22 @@ export class InlineEditor {
260
350
  };
261
351
 
262
352
  el.addEventListener('keydown', onKeyDown);
353
+ el.addEventListener('compositionstart', onCompositionStart);
354
+ el.addEventListener('compositionend', onCompositionEnd);
263
355
  el.addEventListener('blur', onBlur);
264
356
  el.addEventListener('paste', onPaste);
265
357
  document.addEventListener('selectionchange', onSelChange);
266
358
  el.addEventListener('input', onSelChange);
267
359
  el.addEventListener('mouseup', onSelChange);
268
360
  if (mode === 'rich' || mode === 'html') {
269
- el.addEventListener('mousedown', onLinkMouseDown, true);
270
361
  el.addEventListener('click', stopLinkActivate, true);
271
362
  el.addEventListener('auxclick', stopLinkActivate, true);
272
363
  }
273
364
 
274
365
  this.listeners.push(
275
366
  () => el.removeEventListener('keydown', onKeyDown),
367
+ () => el.removeEventListener('compositionstart', onCompositionStart),
368
+ () => el.removeEventListener('compositionend', onCompositionEnd),
276
369
  () => el.removeEventListener('blur', onBlur),
277
370
  () => el.removeEventListener('paste', onPaste),
278
371
  () => document.removeEventListener('selectionchange', onSelChange),
@@ -280,7 +373,6 @@ export class InlineEditor {
280
373
  () => el.removeEventListener('mouseup', onSelChange),
281
374
  ...(mode === 'rich' || mode === 'html'
282
375
  ? [
283
- () => el.removeEventListener('mousedown', onLinkMouseDown, true),
284
376
  () => el.removeEventListener('click', stopLinkActivate, true),
285
377
  () => el.removeEventListener('auxclick', stopLinkActivate, true),
286
378
  ]
@@ -294,6 +386,23 @@ export class InlineEditor {
294
386
  }
295
387
  }
296
388
 
389
+ private _syncPendingHiliteAnchor() {
390
+ if (!this.pendingHiliteColor || !this.pendingHiliteAnchor) return;
391
+ const sel = window.getSelection();
392
+ if (!sel?.rangeCount) {
393
+ this._clearPendingHilite();
394
+ return;
395
+ }
396
+ if (selectionAnchorKey(sel.getRangeAt(0)) !== this.pendingHiliteAnchor) {
397
+ this._clearPendingHilite();
398
+ }
399
+ }
400
+
401
+ private _clearPendingHilite() {
402
+ this.pendingHiliteColor = null;
403
+ this.pendingHiliteAnchor = null;
404
+ }
405
+
297
406
  private _emitSelection() {
298
407
  if (!this.opts.onSelectionChange) return;
299
408
  if (this.opts.mode === 'plain' || this.opts.mode === 'html') {
@@ -312,11 +421,11 @@ export class InlineEditor {
312
421
  const range = sel.getRangeAt(0);
313
422
  const rect = sel.isCollapsed ? this._caretRect(range) : range.getBoundingClientRect();
314
423
 
315
- const align: SelectionState['formats']['align'] = document.queryCommandState('justifyCenter')
424
+ const align: SelectionState['formats']['align'] = richTextQueryCommandState('justifyCenter')
316
425
  ? 'center'
317
- : document.queryCommandState('justifyRight')
426
+ : richTextQueryCommandState('justifyRight')
318
427
  ? 'right'
319
- : document.queryCommandState('justifyFull')
428
+ : richTextQueryCommandState('justifyFull')
320
429
  ? 'justify'
321
430
  : 'left';
322
431
 
@@ -325,21 +434,37 @@ export class InlineEditor {
325
434
  return a?.getAttribute('href') ?? null;
326
435
  })();
327
436
 
437
+ const inline = resolveInlineFormatsAtSelection(this.el, sel);
438
+
439
+ let foreColor = inline.foreColor ?? safeQueryValue('foreColor');
440
+ let backColor = inline.backColor;
441
+ if (
442
+ !backColor &&
443
+ range.collapsed &&
444
+ this.pendingHiliteColor &&
445
+ this.pendingHiliteAnchor === selectionAnchorKey(range)
446
+ ) {
447
+ backColor = this.pendingHiliteColor;
448
+ }
449
+ if (inline.backColor) this._clearPendingHilite();
450
+
328
451
  const state: SelectionState = {
329
452
  hasSelection: true,
330
453
  rect,
331
454
  formats: {
332
- bold: document.queryCommandState('bold'),
333
- italic: document.queryCommandState('italic'),
334
- underline: document.queryCommandState('underline'),
335
- strikethrough: document.queryCommandState('strikeThrough'),
336
- unorderedList: document.queryCommandState('insertUnorderedList'),
337
- orderedList: document.queryCommandState('insertOrderedList'),
455
+ bold: richTextQueryCommandState('bold'),
456
+ italic: richTextQueryCommandState('italic'),
457
+ underline: richTextQueryCommandState('underline'),
458
+ strikethrough: richTextQueryCommandState('strikeThrough'),
459
+ unorderedList: richTextQueryCommandState('insertUnorderedList'),
460
+ orderedList: richTextQueryCommandState('insertOrderedList'),
338
461
  align,
339
462
  link,
340
- foreColor: safeQueryValue('foreColor'),
341
- fontSize: safeQueryValue('fontSize'),
463
+ foreColor,
464
+ fontSize: inline.fontSize,
342
465
  fontName: safeQueryValue('fontName'),
466
+ fontWeight: inline.fontWeight,
467
+ backColor,
343
468
  },
344
469
  };
345
470
  this.lastSelectionState = state;
@@ -351,7 +476,10 @@ export class InlineEditor {
351
476
  if (
352
477
  fallback === null &&
353
478
  this.lastSelectionState &&
354
- document.activeElement?.closest?.('.sm-floating-toolbar')
479
+ isColorPickerOpen() ||
480
+ document.activeElement?.closest?.(
481
+ '.sm-floating-toolbar, .sm-color-picker, .sm-color-picker-layer',
482
+ )
355
483
  ) {
356
484
  this.opts.onSelectionChange?.(this.lastSelectionState);
357
485
  return;
@@ -397,45 +525,158 @@ function placeCaretAtEnd(el: HTMLElement) {
397
525
  sel.addRange(range);
398
526
  }
399
527
 
400
- /** 在视口坐标处设置折叠选区(用于拦截 <a> 默认行为后补回放光标) */
401
- function placeCaretAtClientPoint(root: HTMLElement, clientX: number, clientY: number): boolean {
402
- const doc = root.ownerDocument;
403
- const d = doc as Document & {
404
- caretRangeFromPoint?: (x: number, y: number) => Range | null;
405
- caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null;
406
- };
407
- let range: Range | null = null;
408
- if (d.caretRangeFromPoint) {
409
- range = d.caretRangeFromPoint(clientX, clientY);
410
- } else if (d.caretPositionFromPoint) {
411
- const pos = d.caretPositionFromPoint(clientX, clientY);
412
- if (pos?.offsetNode) {
413
- range = doc.createRange();
414
- try {
415
- range.setStart(pos.offsetNode, pos.offset);
416
- range.collapse(true);
417
- } catch {
418
- range = null;
419
- }
420
- }
421
- }
422
- if (!range || !root.contains(range.startContainer)) return false;
423
- const sel = doc.getSelection();
424
- if (!sel) return false;
425
- sel.removeAllRanges();
426
- sel.addRange(range);
427
- return true;
428
- }
429
-
430
528
  function safeQueryValue(cmd: string): string | null {
431
529
  try {
432
- const v = document.queryCommandValue(cmd);
530
+ const v = richTextQueryCommandValue(cmd);
433
531
  return v ? v : null;
434
532
  } catch {
435
533
  return null;
436
534
  }
437
535
  }
438
536
 
537
+ /** 光标/选区处的字号、字重、文字色、背景色(queryCommandValue 对 span style 与链接内颜色不可靠) */
538
+ function resolveInlineFormatsAtSelection(
539
+ root: HTMLElement,
540
+ sel: Selection,
541
+ ): {
542
+ fontSize: string | null;
543
+ fontWeight: string | null;
544
+ foreColor: string | null;
545
+ backColor: string | null;
546
+ } {
547
+ const host = selectionHostElement(sel, root);
548
+ if (!host) return { fontSize: null, fontWeight: null, foreColor: null, backColor: null };
549
+
550
+ let fontWeight: string | null = null;
551
+ for (let el: HTMLElement | null = host; el && root.contains(el) && el !== root; el = el.parentElement) {
552
+ if (el.style.fontWeight) fontWeight = el.style.fontWeight;
553
+ }
554
+
555
+ const foreColor = resolveForeColorAtSelection(root, host);
556
+ const backColor = resolveBackColorAtSelection(root, host);
557
+ // 用 computed px,避免读到 .sm-text-content 块级默认字号盖掉 <font>/span 上的真实字号
558
+ const fontSize = window.getComputedStyle(host).fontSize;
559
+
560
+ if (!fontWeight) {
561
+ fontWeight = richTextQueryCommandState('bold')
562
+ ? '700'
563
+ : normalizeFontWeightStep(window.getComputedStyle(host).fontWeight);
564
+ } else {
565
+ fontWeight = normalizeFontWeightStep(fontWeight);
566
+ }
567
+
568
+ return { fontSize, fontWeight, foreColor, backColor };
569
+ }
570
+
571
+ /** 光标处文字色(foreColor / 链接局部 color;queryCommandValue 在 <a> 内常不准) */
572
+ function resolveForeColorAtSelection(root: HTMLElement, host: HTMLElement): string | null {
573
+ for (let el: HTMLElement | null = host; el && root.contains(el) && el !== root; el = el.parentElement) {
574
+ const inline = el.style.color?.trim();
575
+ if (inline) return inline;
576
+
577
+ const fromAttr = readColorFromStyleAttr(el.getAttribute('style'));
578
+ if (fromAttr) return fromAttr;
579
+ }
580
+
581
+ const computed = window.getComputedStyle(host).color;
582
+ return computed || null;
583
+ }
584
+
585
+ function readColorFromStyleAttr(styleAttr: string | null): string | null {
586
+ if (!styleAttr) return null;
587
+ const m = styleAttr.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
588
+ if (!m) return null;
589
+ const c = m[1].trim();
590
+ return c || null;
591
+ }
592
+
593
+ /** 光标处文字背景/高亮色(hiliteColor 的 queryCommandValue 在 Chrome 常为空,需读 DOM) */
594
+ function resolveBackColorAtSelection(root: HTMLElement, host: HTMLElement): string | null {
595
+ for (let el: HTMLElement | null = host; el && root.contains(el) && el !== root; el = el.parentElement) {
596
+ const inline = el.style.backgroundColor?.trim();
597
+ if (inline && !isTransparentColor(inline)) return inline;
598
+
599
+ const fromAttr = readBackgroundFromStyleAttr(el.getAttribute('style'));
600
+ if (fromAttr) return fromAttr;
601
+
602
+ const computed = window.getComputedStyle(el).backgroundColor;
603
+ if (computed && !isTransparentColor(computed)) return computed;
604
+ }
605
+
606
+ // 不用 queryCommandValue('hiliteColor'):它返回的是「下次输入用的颜色」,光标在无高亮处时仍会带上一次配置
607
+ return null;
608
+ }
609
+
610
+ function readBackgroundFromStyleAttr(styleAttr: string | null): string | null {
611
+ if (!styleAttr) return null;
612
+ const m = styleAttr.match(/background(?:-color)?\s*:\s*([^;]+)/i);
613
+ if (!m) return null;
614
+ const c = m[1].trim();
615
+ return c && !isTransparentColor(c) ? c : null;
616
+ }
617
+
618
+ function selectionHostElement(sel: Selection, root: HTMLElement): HTMLElement | null {
619
+ const node = sel.anchorNode;
620
+ if (!node || !root.contains(node)) return null;
621
+ return node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as HTMLElement);
622
+ }
623
+
624
+ function selectionAnchorKey(range: Range): string {
625
+ return `${range.startContainer.nodeType}:${range.startOffset}:${range.endContainer.nodeType}:${range.endOffset}`;
626
+ }
627
+
628
+ /** execCommand fontSize 只认 1–7,与工具条 px 互转 */
629
+ function pxToLegacyFontSize(px: string): string {
630
+ const map: Record<string, string> = {
631
+ '10px': '1',
632
+ '12px': '2',
633
+ '13px': '3',
634
+ '14px': '3',
635
+ '15px': '4',
636
+ '16px': '4',
637
+ '18px': '4',
638
+ '20px': '5',
639
+ '24px': '6',
640
+ '28px': '6',
641
+ '32px': '7',
642
+ };
643
+ const key = px.trim().toLowerCase();
644
+ if (map[key]) return map[key];
645
+ const num = parseFloat(key);
646
+ if (Number.isNaN(num)) return '4';
647
+ const steps: [number, string][] = [
648
+ [10, '1'],
649
+ [12, '2'],
650
+ [14, '3'],
651
+ [16, '4'],
652
+ [20, '5'],
653
+ [24, '6'],
654
+ [32, '7'],
655
+ ];
656
+ let best = '4';
657
+ let bestD = Infinity;
658
+ for (const [size, legacy] of steps) {
659
+ const d = Math.abs(size - num);
660
+ if (d < bestD) {
661
+ bestD = d;
662
+ best = legacy;
663
+ }
664
+ }
665
+ return best;
666
+ }
667
+
668
+ function isTransparentColor(c: string): boolean {
669
+ const s = c.trim().toLowerCase();
670
+ if (s === 'transparent' || s === 'inherit' || s === 'initial' || s === 'unset') return true;
671
+ if (s === 'rgba(0, 0, 0, 0)' || s === 'rgba(0,0,0,0)') return true;
672
+ const rgba = s.match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\s*\)$/);
673
+ if (rgba) {
674
+ const a = rgba[4] !== undefined ? parseFloat(rgba[4]) : 1;
675
+ return a === 0;
676
+ }
677
+ return false;
678
+ }
679
+
439
680
  function escapeHtml(s: string): string {
440
681
  return s
441
682
  .replace(/&/g, '&amp;')
@@ -455,7 +696,7 @@ export function sanitizeRichHtml(html: string): string {
455
696
  const tpl = document.createElement('template');
456
697
  tpl.innerHTML = html;
457
698
  walk(tpl.content);
458
- return tpl.innerHTML;
699
+ return tpl.innerHTML.replace(/\u200b/g, '');
459
700
 
460
701
  function walk(root: Node) {
461
702
  const toRemove: Element[] = [];
@@ -15,9 +15,8 @@ interface LayoutCard {
15
15
 
16
16
  const LAYOUT_CARDS: LayoutCard[] = [
17
17
  { layout: '1', name: '一列', icon: layoutSvg([1]) },
18
+ /** 默认 1:1;1:2 / 2:1 在选中 Section 后于右栏「列比例」切换 */
18
19
  { layout: '1-1', name: '两列', icon: layoutSvg([1, 1]) },
19
- { layout: '1-2', name: '1:2', icon: layoutSvg([1, 2]) },
20
- { layout: '2-1', name: '2:1', icon: layoutSvg([2, 1]) },
21
20
  { layout: '1-1-1', name: '三列', icon: layoutSvg([1, 1, 1]) },
22
21
  ];
23
22