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,25 +1,36 @@
1
+ import type { SimpleMailT } from '../i18n';
1
2
  import type { EmailDoc } from '../types';
2
- import { h, clear } from '../utils/dom';
3
- import { parseDocClipboard } from '../utils/docClipboard';
3
+ import {
4
+ type SelectionClipboardEnvelope,
5
+ parseDocClipboard,
6
+ parseSelectionClipboard,
7
+ } from '../utils/docClipboard';
8
+ import { clear, h } from '../utils/dom';
4
9
  import { richTextExecCommand } from '../utils/richTextCommand';
5
10
  import { Modal } from './Modal';
6
- import type { SimpleMailT } from '../i18n';
7
11
 
8
12
  export interface ImportDocModalOptions {
9
13
  /** 读取剪贴板初始内容(失败时返回空字符串) */
10
14
  readClipboard?: () => Promise<string>;
11
15
  onApply: (doc: EmailDoc) => void;
16
+ /** 局部设计稿(simple-mail/selection)回调:追加到画布而非覆盖 */
17
+ onApplySelection?: (envelope: SelectionClipboardEnvelope) => void;
12
18
  t: SimpleMailT;
13
19
  }
14
20
 
15
21
  /**
16
- * 导入设计稿:展示 JSON 文本区,支持从剪贴板填入后覆盖当前画布。
22
+ * 导入设计稿:展示 JSON 文本区,支持从剪贴板填入。
23
+ * - 整稿(simple-mail/doc 或裸 EmailDoc):覆盖当前画布
24
+ * - 局部(simple-mail/selection):追加到当前画布(按钮与提示自动切换)
17
25
  */
18
26
  export class ImportDocModal {
19
27
  private modal: Modal;
20
28
  private textarea: HTMLTextAreaElement;
21
29
  private hintEl: HTMLElement;
30
+ private applyBtn: HTMLButtonElement;
22
31
  private opts: ImportDocModalOptions;
32
+ /** 当前输入识别为局部设计稿时为 true */
33
+ private selectionMode = false;
23
34
 
24
35
  constructor(opts: ImportDocModalOptions) {
25
36
  this.opts = opts;
@@ -44,6 +55,7 @@ export class ImportDocModal {
44
55
  placeholder: t('importDoc.placeholder'),
45
56
  rows: '14',
46
57
  }) as HTMLTextAreaElement;
58
+ this.textarea.addEventListener('input', () => this._syncMode());
47
59
 
48
60
  this.modal.body.append(this.hintEl, this.textarea);
49
61
 
@@ -57,7 +69,7 @@ export class ImportDocModal {
57
69
  { class: 'sm-btn', type: 'button', onclick: () => void this._fillFromClipboard() },
58
70
  [t('importDoc.pasteFromClipboard')],
59
71
  );
60
- const applyBtn = h(
72
+ this.applyBtn = h(
61
73
  'button',
62
74
  {
63
75
  class: 'sm-btn sm-btn--primary',
@@ -65,8 +77,8 @@ export class ImportDocModal {
65
77
  onclick: () => this._apply(),
66
78
  },
67
79
  [t('importDoc.apply')],
68
- );
69
- this.modal.footer.append(cancelBtn, pasteBtn, applyBtn);
80
+ ) as HTMLButtonElement;
81
+ this.modal.footer.append(cancelBtn, pasteBtn, this.applyBtn);
70
82
  }
71
83
 
72
84
  async open(parent?: HTMLElement) {
@@ -75,6 +87,7 @@ export class ImportDocModal {
75
87
  this.modal.open(parent);
76
88
  const initial = (await this.opts.readClipboard?.()) ?? '';
77
89
  if (initial.trim()) this.textarea.value = initial;
90
+ this._syncMode();
78
91
  }
79
92
 
80
93
  private async _fillFromClipboard() {
@@ -85,6 +98,28 @@ export class ImportDocModal {
85
98
  }
86
99
  this.textarea.value = text;
87
100
  this._setHint(this.opts.t('importDoc.clipboardFilled'), false);
101
+ this._syncMode();
102
+ }
103
+
104
+ /** 根据输入内容切换「覆盖整稿 / 追加局部」两种模式(按钮文案与提示)。 */
105
+ private _syncMode() {
106
+ const selection = parseSelectionClipboard(this.textarea.value);
107
+ const next = selection != null && typeof this.opts.onApplySelection === 'function';
108
+ if (next === this.selectionMode) return;
109
+ this.selectionMode = next;
110
+ clear(this.applyBtn);
111
+ this.applyBtn.append(this.opts.t(next ? 'importDoc.applyAppend' : 'importDoc.apply'));
112
+ this.hintEl.classList.remove('sm-import-doc__hint--error');
113
+ clear(this.hintEl);
114
+ if (next) {
115
+ this.hintEl.append(this.opts.t('importDoc.hintSelection'));
116
+ } else {
117
+ this.hintEl.append(
118
+ this.opts.t('importDoc.hint1'),
119
+ this.opts.t('importDoc.hint2'),
120
+ this.opts.t('importDoc.hint3'),
121
+ );
122
+ }
88
123
  }
89
124
 
90
125
  private _apply() {
@@ -93,6 +128,14 @@ export class ImportDocModal {
93
128
  this._setHint(this.opts.t('importDoc.empty'), true);
94
129
  return;
95
130
  }
131
+ if (this.selectionMode) {
132
+ const selection = parseSelectionClipboard(raw);
133
+ if (selection) {
134
+ this.opts.onApplySelection?.(selection);
135
+ this.modal.close();
136
+ return;
137
+ }
138
+ }
96
139
  const doc = parseDocClipboard(raw);
97
140
  if (!doc) {
98
141
  this._setHint(this.opts.t('importDoc.invalid'), true);
@@ -1,3 +1,12 @@
1
+ import type { GlobalStyles } from '../types';
2
+ import {
3
+ LIST_INDENT_DEFAULT_PX,
4
+ type ListIndentValue,
5
+ setListIndent as applyListIndentToElement,
6
+ findListAtSelection,
7
+ getListIndent,
8
+ resolveGlobalListIndentPx,
9
+ } from '../utils/emailListStyles';
1
10
  /**
2
11
  * 把任意 DOM 元素临时变成 contenteditable,并暴露提交/取消/选区事件。
3
12
  *
@@ -10,41 +19,32 @@
10
19
  * - 选区变化通过 `onSelectionChange` 实时回调,便于浮动工具条同步按钮状态/位置。
11
20
  */
12
21
  import { normalizeFontWeightStep } from '../utils/fontWeightSteps';
13
- import {
14
- findListAtSelection,
15
- getListIndent,
16
- LIST_INDENT_DEFAULT_PX,
17
- resolveGlobalListIndentPx,
18
- setListIndent as applyListIndentToElement,
19
- type ListIndentValue,
20
- } from '../utils/emailListStyles';
21
- import type { GlobalStyles } from '../types';
22
22
  import {
23
23
  applyListCommandForSelection,
24
24
  detectListFormats,
25
25
  findListItem,
26
- getListItemsInRange,
27
26
  getCaretTextOffsetAtRangeEnd,
27
+ getListItemsInRange,
28
28
  insertSelectionBoundaryMarkers,
29
- mergeListItemOnBackspace,
30
29
  mergeAdjacentSameTypeLists,
30
+ mergeListItemOnBackspace,
31
31
  mergeNextListItemOnDelete,
32
32
  removeSelectionBoundaryMarkers,
33
33
  restoreCaretAfterListMutation,
34
34
  restoreCaretToEndOfListInSelectionBounds,
35
35
  splitListItemOnEnter,
36
36
  } from '../utils/inlineListEditing';
37
+ import {
38
+ getRichHtmlPlainText,
39
+ hasRichHtmlLineBreak,
40
+ isRichHtmlEditorSeedOnly,
41
+ isRichHtmlEffectivelyEmpty,
42
+ } from '../utils/richHtmlEmpty';
37
43
  import {
38
44
  richTextExecCommand,
39
45
  richTextQueryCommandState,
40
46
  richTextQueryCommandValue,
41
47
  } from '../utils/richTextCommand';
42
- import {
43
- isRichHtmlEffectivelyEmpty,
44
- isRichHtmlEditorSeedOnly,
45
- hasRichHtmlLineBreak,
46
- getRichHtmlPlainText,
47
- } from '../utils/richHtmlEmpty';
48
48
  import {
49
49
  convertFontTags,
50
50
  firstTextNode,
@@ -145,11 +145,7 @@ export class InlineEditor {
145
145
  const text = (this.el.textContent ?? '').replace(/\u200b/g, '');
146
146
  if (text.length > 0) {
147
147
  value = sanitized;
148
- } else if (
149
- this.initiallyEmpty &&
150
- !this.edited &&
151
- isRichHtmlEditorSeedOnly(sanitized)
152
- ) {
148
+ } else if (this.initiallyEmpty && !this.edited && isRichHtmlEditorSeedOnly(sanitized)) {
153
149
  value = '';
154
150
  } else if (hasRichHtmlLineBreak(sanitized) && !this.hadTextInSession) {
155
151
  // 从未有过文本,仅换行(含单个 <br>)视为有效
@@ -209,9 +205,7 @@ export class InlineEditor {
209
205
  const isListCmd = command === 'insertOrderedList' || command === 'insertUnorderedList';
210
206
  if (isListCmd && sel?.rangeCount) {
211
207
  const range = sel.getRangeAt(0);
212
- const itemsInRange = range.collapsed
213
- ? []
214
- : getListItemsInRange(range, this.el);
208
+ const itemsInRange = range.collapsed ? [] : getListItemsInRange(range, this.el);
215
209
  const savedOffset = getCaretTextOffsetAtRangeEnd(this.el, range);
216
210
  const boundaryMarkers = insertSelectionBoundaryMarkers(range);
217
211
  this.suppressListMerge = true;
@@ -514,8 +508,7 @@ export class InlineEditor {
514
508
  el.classList.remove('is-empty');
515
509
 
516
510
  const initial = this.opts.initialValue ?? '';
517
- const initiallyEmpty =
518
- mode === 'plain' ? !initial.trim() : isRichHtmlEffectivelyEmpty(initial);
511
+ const initiallyEmpty = mode === 'plain' ? !initial.trim() : isRichHtmlEffectivelyEmpty(initial);
519
512
  this.initiallyEmpty = initiallyEmpty;
520
513
  this.edited = false;
521
514
  this.hadTextInSession =
@@ -625,7 +618,8 @@ export class InlineEditor {
625
618
  const active = document.activeElement as HTMLElement | null;
626
619
  if (active && (active === el || el.contains(active))) return;
627
620
  if (isColorPickerOpen()) return;
628
- if (active?.closest?.('.sm-floating-toolbar, .sm-color-picker, .sm-color-picker-layer')) return;
621
+ if (active?.closest?.('.sm-floating-toolbar, .sm-color-picker, .sm-color-picker-layer'))
622
+ return;
629
623
  if (active?.closest?.('.sm-topbar, .sm-popover, .sm-panel--variable-picker')) return;
630
624
  // 从左栏拖入 / 画布内 Sortable 排序:mousedown 会先 blur,若在此时提交会触发画布重渲染并拆掉 drop 目标
631
625
  if (active?.closest?.('.sm-panel--left')) return;
@@ -787,7 +781,7 @@ export class InlineEditor {
787
781
 
788
782
  const inline = resolveInlineFormatsAtSelection(this.el, sel);
789
783
 
790
- let foreColor = inline.foreColor ?? safeQueryValue('foreColor');
784
+ const foreColor = inline.foreColor ?? safeQueryValue('foreColor');
791
785
  let backColor = inline.backColor;
792
786
  if (
793
787
  !backColor &&
@@ -805,8 +799,7 @@ export class InlineEditor {
805
799
  const listIndentGlobalPx = this.opts.globalStyles
806
800
  ? resolveGlobalListIndentPx(this.opts.globalStyles)
807
801
  : LIST_INDENT_DEFAULT_PX;
808
- const listIndentEffectivePx =
809
- listIndentPx != null ? listIndentPx : listIndentGlobalPx;
802
+ const listIndentEffectivePx = listIndentPx != null ? listIndentPx : listIndentGlobalPx;
810
803
 
811
804
  const state: SelectionState = {
812
805
  hasSelection: true,
@@ -839,9 +832,7 @@ export class InlineEditor {
839
832
  /** 焦点在浮动工具条上时沿用上一帧选区,避免点开链接框 / 颜色等控件后工具条消失。 */
840
833
  private _emitSelectionOrKeepToolbar(fallback: null) {
841
834
  if (
842
- fallback === null &&
843
- this.lastSelectionState &&
844
- isColorPickerOpen() ||
835
+ (fallback === null && this.lastSelectionState && isColorPickerOpen()) ||
845
836
  document.activeElement?.closest?.(
846
837
  '.sm-floating-toolbar, .sm-color-picker, .sm-color-picker-layer',
847
838
  )
@@ -870,7 +861,8 @@ export class InlineEditor {
870
861
 
871
862
  /* ----------------------------- helpers ----------------------------- */
872
863
 
873
- function findAncestorAnchor(node: Node | null, root: HTMLElement): HTMLAnchorElement | null {
864
+ function findAncestorAnchor(start: Node | null, root: HTMLElement): HTMLAnchorElement | null {
865
+ let node = start;
874
866
  while (node && node !== root) {
875
867
  if (node.nodeType === 1 && (node as HTMLElement).tagName === 'A') {
876
868
  return node as HTMLAnchorElement;
@@ -913,7 +905,11 @@ function resolveInlineFormatsAtSelection(
913
905
  if (!host) return { fontSize: null, fontWeight: null, foreColor: null, backColor: null };
914
906
 
915
907
  let fontWeight: string | null = null;
916
- for (let el: HTMLElement | null = host; el && root.contains(el) && el !== root; el = el.parentElement) {
908
+ for (
909
+ let el: HTMLElement | null = host;
910
+ el && root.contains(el) && el !== root;
911
+ el = el.parentElement
912
+ ) {
917
913
  if (el.style.fontWeight) fontWeight = el.style.fontWeight;
918
914
  }
919
915
 
@@ -1033,7 +1029,11 @@ function resolveUnderlineAtSelection(root: HTMLElement, sel: Selection): boolean
1033
1029
 
1034
1030
  /** 光标处文字色(foreColor / 链接局部 color;queryCommandValue 在 <a> 内常不准) */
1035
1031
  function resolveForeColorAtSelection(root: HTMLElement, host: HTMLElement): string | null {
1036
- for (let el: HTMLElement | null = host; el && root.contains(el) && el !== root; el = el.parentElement) {
1032
+ for (
1033
+ let el: HTMLElement | null = host;
1034
+ el && root.contains(el) && el !== root;
1035
+ el = el.parentElement
1036
+ ) {
1037
1037
  const inline = el.style.color?.trim();
1038
1038
  if (inline) return inline;
1039
1039
 
@@ -1055,7 +1055,11 @@ function readColorFromStyleAttr(styleAttr: string | null): string | null {
1055
1055
 
1056
1056
  /** 光标处文字背景/高亮色(hiliteColor 的 queryCommandValue 在 Chrome 常为空,需读 DOM) */
1057
1057
  function resolveBackColorAtSelection(root: HTMLElement, host: HTMLElement): string | null {
1058
- for (let el: HTMLElement | null = host; el && root.contains(el) && el !== root; el = el.parentElement) {
1058
+ for (
1059
+ let el: HTMLElement | null = host;
1060
+ el && root.contains(el) && el !== root;
1061
+ el = el.parentElement
1062
+ ) {
1059
1063
  const inline = el.style.backgroundColor?.trim();
1060
1064
  if (inline && !isTransparentColor(inline)) return inline;
1061
1065
 
@@ -1103,7 +1107,7 @@ function isTransparentColor(c: string): boolean {
1103
1107
  if (s === 'rgba(0, 0, 0, 0)' || s === 'rgba(0,0,0,0)') return true;
1104
1108
  const rgba = s.match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\s*\)$/);
1105
1109
  if (rgba) {
1106
- const a = rgba[4] !== undefined ? parseFloat(rgba[4]) : 1;
1110
+ const a = rgba[4] !== undefined ? Number.parseFloat(rgba[4]) : 1;
1107
1111
  return a === 0;
1108
1112
  }
1109
1113
  return false;
@@ -1,9 +1,9 @@
1
1
  import Sortable from 'sortablejs';
2
+ import type { SimpleMailT } from '../i18n';
2
3
  import type { Registry } from '../registry/registry';
3
4
  import type { BlockDefinition, SectionLayout } from '../types';
4
5
  import { clear, h } from '../utils/dom';
5
6
  import { paletteDropIsSectionLevel } from '../utils/paletteDrop';
6
- import type { SimpleMailT } from '../i18n';
7
7
 
8
8
  export interface LeftPanelOptions {
9
9
  registry: Registry;
@@ -173,9 +173,7 @@ export class LeftPanel {
173
173
  'div',
174
174
  {
175
175
  class:
176
- sourceGroup === 'sections'
177
- ? 'sm-block-card sm-block-card--section'
178
- : 'sm-block-card',
176
+ sourceGroup === 'sections' ? 'sm-block-card sm-block-card--section' : 'sm-block-card',
179
177
  'data-source-group': sourceGroup,
180
178
  'data-block-type': def.type,
181
179
  title: this._blockCardTitle(def),
@@ -1,5 +1,5 @@
1
- import { h } from '../utils/dom';
2
1
  import type { SimpleMailT } from '../i18n';
2
+ import { h } from '../utils/dom';
3
3
 
4
4
  /**
5
5
  * 极简 Modal:遮罩 + 居中卡片 + header/body/footer 三段。
@@ -1,9 +1,9 @@
1
+ import type { SimpleMailT } from '../i18n';
1
2
  import type { Registry } from '../registry/registry';
2
3
  import { renderDoc } from '../renderer';
3
4
  import type { Store } from '../store/store';
4
5
  import { h } from '../utils/dom';
5
6
  import { Modal } from './Modal';
6
- import type { SimpleMailT } from '../i18n';
7
7
 
8
8
  export type PreviewDevice = 'pc' | 'pad' | 'phone';
9
9
 
@@ -98,9 +98,7 @@ export class PreviewModal {
98
98
  for (const k of Object.keys(this.deviceBtns) as PreviewDevice[]) {
99
99
  this.deviceBtns[k].classList.toggle('sm-segmented__item--active', k === d);
100
100
  }
101
- requestAnimationFrame(() =>
102
- requestAnimationFrame(() => this._scheduleFitPreviewHeight()),
103
- );
101
+ requestAnimationFrame(() => requestAnimationFrame(() => this._scheduleFitPreviewHeight()));
104
102
  }
105
103
 
106
104
  private _render() {
@@ -130,10 +128,7 @@ export class PreviewModal {
130
128
  const body = doc?.body;
131
129
  if (!root || !body) return;
132
130
  const raw = Math.max(root.scrollHeight, body.scrollHeight);
133
- const maxPx = Math.max(
134
- 240,
135
- window.innerHeight - PreviewModal.VIEWPORT_RESERVED_Y,
136
- );
131
+ const maxPx = Math.max(240, window.innerHeight - PreviewModal.VIEWPORT_RESERVED_Y);
137
132
  const minPx = 200;
138
133
  const next = Math.min(Math.max(Math.ceil(raw), minPx), maxPx);
139
134
  this.viewport.style.height = `${next}px`;
@@ -1,9 +1,9 @@
1
- import { bindColorPickerInput } from './ColorPickerPopover';
1
+ import type { SimpleMailT } from '../i18n';
2
2
  import { h } from '../utils/dom';
3
- import { FONT_WEIGHT_STEP_OPTIONS, normalizeFontWeightStep } from '../utils/fontWeightSteps';
4
3
  import { LIST_INDENT_PRESETS_PX, type ListIndentValue } from '../utils/emailListStyles';
4
+ import { FONT_WEIGHT_STEP_OPTIONS, normalizeFontWeightStep } from '../utils/fontWeightSteps';
5
+ import { bindColorPickerInput } from './ColorPickerPopover';
5
6
  import type { InlineEditor, SelectionState } from './InlineEditor';
6
- import type { SimpleMailT } from '../i18n';
7
7
 
8
8
  /**
9
9
  * 富文本浮动工具条。
@@ -31,8 +31,16 @@ export interface RichTextToolbarOptions {
31
31
  const FONT_FAMILIES = [
32
32
  { labelKey: 'toolbar.fontDefault', label: 'Default', value: '' },
33
33
  { label: 'Inter', value: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif' },
34
- { labelKey: 'toolbar.fontPingFang', label: 'PingFang SC', value: '"PingFang SC", "Helvetica Neue", Arial, sans-serif' },
35
- { labelKey: 'toolbar.fontMicrosoftYaHei', label: 'Microsoft YaHei', value: '"Microsoft YaHei", "Segoe UI", sans-serif' },
34
+ {
35
+ labelKey: 'toolbar.fontPingFang',
36
+ label: 'PingFang SC',
37
+ value: '"PingFang SC", "Helvetica Neue", Arial, sans-serif',
38
+ },
39
+ {
40
+ labelKey: 'toolbar.fontMicrosoftYaHei',
41
+ label: 'Microsoft YaHei',
42
+ value: '"Microsoft YaHei", "Segoe UI", sans-serif',
43
+ },
36
44
  { labelKey: 'toolbar.fontSimSun', label: 'SimSun', value: 'SimSun, "Songti SC", serif' },
37
45
  { label: 'Helvetica', value: '"Helvetica Neue", Arial, sans-serif' },
38
46
  { label: 'Arial', value: 'Arial, sans-serif' },
@@ -144,9 +152,7 @@ export class RichTextToolbar {
144
152
  const hex = colorToHexInput(f.foreColor);
145
153
  if (hex) this.inputColor.value = hex;
146
154
  }
147
- this.inputBgColor.value = f.backColor
148
- ? (colorToHexInput(f.backColor) ?? '#ffffff')
149
- : '#ffffff';
155
+ this.inputBgColor.value = f.backColor ? (colorToHexInput(f.backColor) ?? '#ffffff') : '#ffffff';
150
156
  if (f.fontName) this.selectFontFamily.value = matchFamily(f.fontName);
151
157
  this.selectFontSize.value = matchFontSize(f.fontSize);
152
158
  this.selectFontWeight.value = matchFontWeight(f.fontWeight);
@@ -173,9 +179,14 @@ export class RichTextToolbar {
173
179
  this.btns.bold = this._btn('B', t('toolbar.bold'), exec('bold'), { fontWeight: '700' });
174
180
  this.btns.italic = this._btn('I', t('toolbar.italic'), exec('italic'), { fontStyle: 'italic' });
175
181
  // 链接内的下划线归 <a> 管,走专用实现;非链接文本回落到 execCommand
176
- this.btns.underline = this._btn('U', t('toolbar.underline'), () => this.editor?.toggleUnderline(), {
177
- textDecoration: 'underline',
178
- });
182
+ this.btns.underline = this._btn(
183
+ 'U',
184
+ t('toolbar.underline'),
185
+ () => this.editor?.toggleUnderline(),
186
+ {
187
+ textDecoration: 'underline',
188
+ },
189
+ );
179
190
  this.btns.strikethrough = this._btn('S', t('toolbar.strikethrough'), exec('strikeThrough'), {
180
191
  textDecoration: 'line-through',
181
192
  });
@@ -585,13 +596,13 @@ function matchFontSize(raw: string | null): string {
585
596
  if (!raw) return '';
586
597
  const m = raw.trim().match(/^(\d+(?:\.\d+)?)px$/i);
587
598
  if (!m) return '';
588
- const px = `${Math.round(parseFloat(m[1]))}px`;
599
+ const px = `${Math.round(Number.parseFloat(m[1]))}px`;
589
600
  if (FONT_SIZES.includes(px)) return px;
590
- const num = parseFloat(px);
601
+ const num = Number.parseFloat(px);
591
602
  let best = FONT_SIZES[0];
592
- let bestD = Infinity;
603
+ let bestD = Number.POSITIVE_INFINITY;
593
604
  for (const s of FONT_SIZES) {
594
- const d = Math.abs(parseFloat(s) - num);
605
+ const d = Math.abs(Number.parseFloat(s) - num);
595
606
  if (d < bestD) {
596
607
  bestD = d;
597
608
  best = s;