mail-editor-pancake 0.0.0 → 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.
Files changed (45) hide show
  1. package/README.md +79 -6
  2. package/package.json +1 -1
  3. package/packages/blocks/dist/index.d.ts +55 -3
  4. package/packages/blocks/dist/index.js +267 -208
  5. package/packages/blocks/dist/index.js.map +1 -1
  6. package/packages/blocks/node_modules/.bin/resolve +21 -0
  7. package/packages/blocks/package.json +1 -0
  8. package/packages/blocks/src/button.ts +1 -0
  9. package/packages/blocks/src/hero.ts +2 -0
  10. package/packages/blocks/src/image.ts +1 -0
  11. package/packages/blocks/src/index.ts +19 -2
  12. package/packages/blocks/src/logo.ts +1 -0
  13. package/packages/blocks/src/social.ts +47 -22
  14. package/packages/blocks/src/socialGroup.ts +49 -22
  15. package/packages/blocks/src/socialShared.ts +39 -2
  16. package/packages/blocks/src/text.ts +25 -19
  17. package/packages/blocks/tsconfig.json +0 -3
  18. package/packages/blocks/vite.config.ts +2 -0
  19. package/packages/core/dist/index.d.ts +90 -7
  20. package/packages/core/dist/index.js +2505 -1462
  21. package/packages/core/dist/index.js.map +1 -1
  22. package/packages/core/dist/style.css +1 -1
  23. package/packages/core/src/editor/Canvas.ts +107 -53
  24. package/packages/core/src/editor/ColorPickerPopover.ts +291 -0
  25. package/packages/core/src/editor/Editor.ts +129 -8
  26. package/packages/core/src/editor/ExportModal.ts +2 -1
  27. package/packages/core/src/editor/FocusBreadcrumb.ts +120 -0
  28. package/packages/core/src/editor/ImageGalleryModal.ts +3 -3
  29. package/packages/core/src/editor/InlineEditor.ts +308 -66
  30. package/packages/core/src/editor/LeftPanel.ts +1 -2
  31. package/packages/core/src/editor/PreviewModal.ts +77 -7
  32. package/packages/core/src/editor/RichTextToolbar.ts +66 -44
  33. package/packages/core/src/editor/RightPanel.ts +773 -171
  34. package/packages/core/src/editor/SourceView.ts +2 -1
  35. package/packages/core/src/editor/Topbar.ts +150 -5
  36. package/packages/core/src/editor/styles.css +644 -81
  37. package/packages/core/src/index.ts +18 -1
  38. package/packages/core/src/renderer/mjml.ts +2 -1
  39. package/packages/core/src/store/store.ts +44 -3
  40. package/packages/core/src/types.ts +34 -3
  41. package/packages/core/src/utils/fontWeightSteps.ts +31 -0
  42. package/packages/core/src/utils/lockedMjml.ts +2 -1
  43. package/packages/core/src/utils/richTextCommand.ts +30 -0
  44. package/packages/core/src/utils/sectionLayout.ts +13 -0
  45. package/playground/vanilla/src/main.ts +1 -1
@@ -2,18 +2,30 @@ import { EditorView, basicSetup } from 'codemirror';
2
2
  import { html as cmHtml } from '@codemirror/lang-html';
3
3
  import { EditorState } from '@codemirror/state';
4
4
  import type { Registry } from '../registry/registry';
5
- import { findBlockLocation, findSection, type Store, type DocChangedDetail } from '../store/store';
5
+ import {
6
+ findBlockLocation,
7
+ findSection,
8
+ isTwoColumnLayout,
9
+ setSectionLayout,
10
+ type Store,
11
+ type DocChangedDetail,
12
+ } from '../store/store';
6
13
  import type {
7
14
  Block,
8
15
  BlockSchemaField,
9
16
  EditorUiOptions,
10
17
  EmailDoc,
11
18
  Section,
19
+ SectionLayout,
12
20
  } from '../types';
13
21
  import { defaultSocialIconBackground } from '../socialDefaults';
22
+ import { bindColorPickerInput } from './ColorPickerPopover';
23
+ import { normalizeAccentHex } from '../utils/accentColor';
14
24
  import { clear, h } from '../utils/dom';
15
25
  import { metaWidthInputString, parseMetaWidthFromUserInput, parseSectionWidthFromUserInput, sectionWidthInputString } from '../utils/contentWidth';
26
+ import { FONT_WEIGHT_STEP_OPTIONS, normalizeFontWeightStep } from '../utils/fontWeightSteps';
16
27
  import type { ImageAssetsHandlers, ImageFieldContext } from './imageAssets';
28
+ import { FocusBreadcrumb } from './FocusBreadcrumb';
17
29
  import { openImageGalleryModal } from './ImageGalleryModal';
18
30
 
19
31
  /** 与社交组 block 中每行元素结构一致(core 不依赖 blocks) */
@@ -47,6 +59,12 @@ function layoutWidthNumeric(raw: string, mode: 'px' | '%'): number {
47
59
  export interface RightPanelOptions {
48
60
  store: Store;
49
61
  registry: Registry;
62
+ /** 聚焦路径根节点文案(文档级),默认「邮件」 */
63
+ docRootLabel?: string;
64
+ /** 点击路径中的 Section 层级 */
65
+ onFocusSection: (sectionId: string) => void;
66
+ /** 点击路径中的文档级层级(等同 Esc 从 Section 上浮) */
67
+ onFocusDocument: () => void;
50
68
  /**
51
69
  * 可选。配置后,schema 中 `type: 'image'` 的字段会显示「上传 / 图床」按钮并调用对应回调。
52
70
  */
@@ -64,6 +82,9 @@ type RightTab = 'props' | 'code';
64
82
  export class RightPanel {
65
83
  el: HTMLElement;
66
84
  private opts: RightPanelOptions;
85
+ private headEl: HTMLElement;
86
+ private contentEl: HTMLElement;
87
+ private focusCrumb: FocusBreadcrumb;
67
88
  private currentTab: RightTab = 'props';
68
89
  private codeView: EditorView | null = null;
69
90
 
@@ -78,6 +99,17 @@ export class RightPanel {
78
99
  constructor(opts: RightPanelOptions) {
79
100
  this.opts = opts;
80
101
  this.el = h('aside', { class: 'sm-panel sm-panel--right' });
102
+ this.focusCrumb = new FocusBreadcrumb({
103
+ store: opts.store,
104
+ registry: opts.registry,
105
+ docRootLabel: opts.docRootLabel ?? '邮件',
106
+ onFocusDocument: () => opts.onFocusDocument(),
107
+ onFocusSection: (id) => opts.onFocusSection(id),
108
+ });
109
+ this.headEl = h('div', { class: 'sm-panel__head' }, [this.focusCrumb.el]);
110
+ this.contentEl = h('div', { class: 'sm-panel__body' });
111
+ this.el.append(this.headEl, this.contentEl);
112
+ this._syncHeadVisibility();
81
113
  this._render();
82
114
  opts.store.subscribe((detail?: DocChangedDetail) => {
83
115
  if (detail?.source === 'history') {
@@ -94,10 +126,17 @@ export class RightPanel {
94
126
  });
95
127
  opts.store.subscribeSelection(() => {
96
128
  this.currentTab = 'props';
129
+ this._syncHeadVisibility();
97
130
  this._render();
98
131
  });
99
132
  }
100
133
 
134
+ /** 文档级(无选中)时不展示面包屑,避免与「邮件设置」标题重复 */
135
+ private _syncHeadVisibility() {
136
+ const show = !!this.opts.store.selection;
137
+ this.headEl.hidden = !show;
138
+ }
139
+
101
140
  /** 焦点在右栏可编辑控件上时不要整栏重渲染(避免输入/CodeMirror 失焦) */
102
141
  private _shouldSkipRenderBecauseFocused(): boolean {
103
142
  const ae = document.activeElement as HTMLElement | null;
@@ -143,20 +182,20 @@ export class RightPanel {
143
182
 
144
183
  private _render() {
145
184
  this._destroyCodeView();
146
- clear(this.el);
185
+ clear(this.contentEl);
147
186
 
148
187
  const sel = this.opts.store.selection;
149
188
  const doc = this.opts.store.doc;
150
189
 
151
190
  if (!sel) {
152
- this.el.append(this._renderDocForm(doc));
191
+ this.contentEl.append(this._renderDocForm(doc));
153
192
  return;
154
193
  }
155
194
 
156
195
  if (sel.kind === 'section') {
157
196
  const section = findSection(doc, sel.sectionId);
158
197
  if (!section) return;
159
- this.el.append(this._renderSectionForm(section));
198
+ this.contentEl.append(this._renderSectionForm(section));
160
199
  return;
161
200
  }
162
201
 
@@ -167,8 +206,8 @@ export class RightPanel {
167
206
  this._tabBtn('props', '属性'),
168
207
  this._tabBtn('code', '代码'),
169
208
  ]);
170
- this.el.append(tabs);
171
- this.el.append(
209
+ this.contentEl.append(tabs);
210
+ this.contentEl.append(
172
211
  h('div', { class: 'sm-empty-form' }, ['该组件可能已被删除,请重新选中画布中的块。']),
173
212
  );
174
213
  return;
@@ -179,11 +218,11 @@ export class RightPanel {
179
218
  this._tabBtn('props', '属性'),
180
219
  this._tabBtn('code', '代码'),
181
220
  ]);
182
- this.el.append(tabs);
221
+ this.contentEl.append(tabs);
183
222
 
184
223
  if (this.currentTab === 'props') {
185
224
  if (loc.block.lockedMjml) {
186
- this.el.append(
225
+ this.contentEl.append(
187
226
  h('div', { class: 'sm-empty-form' }, [
188
227
  '该组件已被代码模式锁定。',
189
228
  h('div', { style: 'margin-top:8px;' }, [
@@ -205,14 +244,14 @@ export class RightPanel {
205
244
  ]),
206
245
  );
207
246
  } else if (def) {
208
- this.el.append(this._renderBlockForm(loc.block, def.schema));
247
+ this.contentEl.append(this._renderBlockForm(loc.block, def.schema));
209
248
  } else {
210
- this.el.append(
249
+ this.contentEl.append(
211
250
  h('div', { class: 'sm-empty-form' }, [`未注册的组件: ${loc.block.type}`]),
212
251
  );
213
252
  }
214
253
  } else {
215
- this.el.append(this._renderBlockCodeView(loc.block));
254
+ this.contentEl.append(this._renderBlockCodeView(loc.block));
216
255
  }
217
256
  }
218
257
 
@@ -306,7 +345,7 @@ export class RightPanel {
306
345
  'doc:styles.fontSize',
307
346
  ),
308
347
  this._docFontWeightField(
309
- doc.styles.fontWeight ?? 'normal',
348
+ doc.styles.fontWeight ?? '400',
310
349
  (v) =>
311
350
  this.opts.store.update((d) => {
312
351
  d.styles.fontWeight = v;
@@ -351,6 +390,19 @@ export class RightPanel {
351
390
  const a = section.attrs;
352
391
  return h('form', { class: 'sm-form', onsubmit: (e: Event) => e.preventDefault() }, [
353
392
  h('div', { class: 'sm-panel__title' }, ['Section 设置']),
393
+ ...(isTwoColumnLayout(section.layout)
394
+ ? [
395
+ this._twoColumnRatioField(
396
+ section.layout,
397
+ (layout) =>
398
+ this.opts.store.update((d) => {
399
+ const s = findSection(d, section.id);
400
+ if (s) setSectionLayout(s, layout);
401
+ }),
402
+ `section:${section.id}:layout`,
403
+ ),
404
+ ]
405
+ : []),
354
406
  this._colorField(
355
407
  '背景色',
356
408
  a.backgroundColor ?? '',
@@ -360,6 +412,7 @@ export class RightPanel {
360
412
  if (s) s.attrs.backgroundColor = v || undefined;
361
413
  }),
362
414
  `section:${section.id}:attrs.bg`,
415
+ '留空=透明',
363
416
  ),
364
417
  this._layoutWidthField(
365
418
  '区域宽度',
@@ -439,12 +492,16 @@ export class RightPanel {
439
492
  private _field(field: BlockSchemaField, block: Block): HTMLElement {
440
493
  const blockId = block.id;
441
494
  const fp = `block:${blockId}:${field.key}`;
442
- const value = (block.props as any)[field.key];
495
+ const def = this.opts.registry.get(block.type);
496
+ const defaults = def?.defaultProps as Record<string, unknown> | undefined;
497
+ const raw = (block.props as Record<string, unknown>)[field.key];
498
+ const value: unknown =
499
+ raw !== undefined && raw !== null ? raw : defaults?.[field.key];
443
500
  const onChange = (v: any) => this._updateBlock(blockId, field.key, v);
444
501
 
445
502
  switch (field.type) {
446
503
  case 'textarea':
447
- return this._textareaField(field.label, value ?? '', onChange, field.help, fp);
504
+ return this._textareaField(field.label, String(value ?? ''), onChange, field.help, fp);
448
505
  case 'number':
449
506
  return this._numberField(
450
507
  field.label,
@@ -455,16 +512,54 @@ export class RightPanel {
455
512
  field.step ?? 1,
456
513
  fp,
457
514
  );
458
- case 'color':
459
- return this._colorField(field.label, value ?? '', onChange, fp);
460
- case 'select':
461
- return this._selectField(field.label, value ?? '', field.options ?? [], onChange, fp);
515
+ case 'color': {
516
+ const rawColor = (block.props as Record<string, unknown>)[field.key];
517
+ const stored =
518
+ rawColor !== undefined && rawColor !== null ? String(rawColor).trim() : '';
519
+ return this._colorField(
520
+ field.label,
521
+ stored,
522
+ (v) => onChange(v.trim()),
523
+ fp,
524
+ field.placeholder ?? '',
525
+ );
526
+ }
527
+ case 'select': {
528
+ const isFw = field.key === 'fontWeight' || field.key === 'labelFontWeight';
529
+ const opts = field.options?.length
530
+ ? field.options
531
+ : isFw
532
+ ? FONT_WEIGHT_STEP_OPTIONS
533
+ : [];
534
+ if (isFw && field.inheritGlobal) {
535
+ const stored =
536
+ raw !== undefined && raw !== null ? String(raw).trim() : '';
537
+ return this._fontWeightInheritField(
538
+ field.label,
539
+ stored,
540
+ normalizeFontWeightStep(this.opts.store.doc.styles.fontWeight),
541
+ (v) => onChange(v),
542
+ fp,
543
+ opts,
544
+ field.help,
545
+ );
546
+ }
547
+ const displayVal = isFw
548
+ ? normalizeFontWeightStep(String(value ?? ''))
549
+ : String(value ?? '');
550
+ if (isFw || field.selectVariant === 'segmented') {
551
+ const el = this._segmentedSelectField(field.label, displayVal, opts, onChange, fp);
552
+ if (field.help) el.append(h('div', { class: 'sm-field__help' }, [field.help]));
553
+ return el;
554
+ }
555
+ return this._selectField(field.label, displayVal, opts, onChange, fp);
556
+ }
462
557
  case 'switch':
463
558
  return this._switchField(field.label, !!value, onChange, field.help, fp);
464
559
  case 'image':
465
560
  return this._imageField(
466
561
  field.label,
467
- value ?? '',
562
+ String(value ?? ''),
468
563
  onChange,
469
564
  field.placeholder ?? 'https://',
470
565
  fp,
@@ -473,32 +568,33 @@ export class RightPanel {
473
568
  case 'url':
474
569
  case 'text': {
475
570
  if (field.key === 'width' && this._preferSliderControls()) {
476
- const el = this._layoutWidthField(
571
+ return this._layoutWidthField(
477
572
  field.label,
478
573
  String(value ?? ''),
479
574
  (v) => onChange(v),
480
575
  fp,
481
576
  field.help,
482
577
  );
483
- if (field.placeholder && !field.help) {
484
- el.append(h('div', { class: 'sm-field__help' }, [field.placeholder]));
485
- }
486
- return el;
487
578
  }
488
- if (field.key === 'fontSize' && this._preferSliderControls()) {
489
- const el = this._fontSizeSliderField(
579
+ if (field.key === 'fontSize' && field.inheritGlobal) {
580
+ const rawFs =
581
+ raw !== undefined && raw !== null ? String(raw).trim() : '';
582
+ return this._fontSizeSliderField(
490
583
  field.label,
491
- String(value ?? ''),
492
- (v) => onChange(v),
584
+ rawFs,
585
+ (v) => onChange(v.trim()),
493
586
  fp,
494
587
  field.help,
588
+ this.opts.store.doc.styles.fontSize,
495
589
  );
496
- if (field.placeholder && !field.help) {
497
- el.append(h('div', { class: 'sm-field__help' }, [field.placeholder]));
498
- }
499
- return el;
500
590
  }
501
- const row = this._textField(field.label, value ?? '', onChange, field.placeholder ?? '', fp);
591
+ const row = this._textField(
592
+ field.label,
593
+ String(value ?? ''),
594
+ onChange,
595
+ field.placeholder ?? '',
596
+ fp,
597
+ );
502
598
  if (field.help) row.append(h('div', { class: 'sm-field__help' }, [field.help]));
503
599
  return row;
504
600
  }
@@ -528,16 +624,22 @@ export class RightPanel {
528
624
  });
529
625
  }, `${fp}:pad`, resetQuad);
530
626
  }
531
- case 'socialLinkList':
627
+ case 'socialLinkList': {
628
+ const rows = Array.isArray(value)
629
+ ? (value as SocialLinkRow[])
630
+ : Array.isArray(defaults?.[field.key])
631
+ ? (defaults[field.key] as SocialLinkRow[])
632
+ : [];
532
633
  return this._socialLinkListField(
533
634
  field.label,
534
- Array.isArray(value) ? (value as SocialLinkRow[]) : [],
635
+ rows,
535
636
  field.options ?? [],
536
637
  blockId,
537
638
  field.key,
538
639
  field.help,
539
640
  fp,
540
641
  );
642
+ }
541
643
  default:
542
644
  return h('div');
543
645
  }
@@ -653,16 +755,18 @@ export class RightPanel {
653
755
  ...(focusPrefix ? { 'data-sm-focus': `${focusPrefix}:row:${i}:icon` } : {}),
654
756
  oninput: (e: Event) => syncItem(i, { iconSrc: (e.target as HTMLInputElement).value }),
655
757
  }),
656
- h('input', {
657
- class: 'sm-social-link-list__color sm-social-link-list__color--compact',
658
- type: 'color',
659
- title: '图标背景色',
660
- value: bgDisplay.length >= 4 ? bgDisplay : bgResolved,
661
- ...(focusPrefix ? { 'data-sm-focus': `${focusPrefix}:row:${i}:bg` } : {}),
662
- oninput: (e: Event) =>
663
- syncItem(i, { backgroundColor: (e.target as HTMLInputElement).value }),
664
- }),
665
758
  );
759
+ const bgPick = h('input', {
760
+ class: 'sm-social-link-list__color sm-social-link-list__color--compact',
761
+ type: 'color',
762
+ value: bgDisplay.length >= 4 ? bgDisplay : bgResolved,
763
+ ...(focusPrefix ? { 'data-sm-focus': `${focusPrefix}:row:${i}:bg` } : {}),
764
+ }) as HTMLInputElement;
765
+ bindColorPickerInput(bgPick, {
766
+ liveCommit: true,
767
+ onCommit: (hex) => syncItem(i, { backgroundColor: hex }),
768
+ });
769
+ extras.append(bgPick);
666
770
  rowWrap.append(extras);
667
771
 
668
772
  listEl.append(rowWrap);
@@ -886,21 +990,53 @@ export class RightPanel {
886
990
  };
887
991
 
888
992
  if (!this._preferSliderControls()) {
993
+ const inp = h('input', {
994
+ class: 'sm-input',
995
+ type: 'number',
996
+ value: String(value),
997
+ min,
998
+ max,
999
+ step,
1000
+ ...(focusToken ? { 'data-sm-focus': focusToken } : {}),
1001
+ }) as HTMLInputElement;
1002
+
1003
+ const commitPlain = () => {
1004
+ const t = inp.value.trim();
1005
+ const fb = snap(value);
1006
+ if (t === '') {
1007
+ inp.value = String(fb);
1008
+ onChange(fb);
1009
+ return;
1010
+ }
1011
+ const v = Number(t);
1012
+ const s = snap(Number.isNaN(v) ? fb : v);
1013
+ inp.value = String(s);
1014
+ onChange(s);
1015
+ };
1016
+
1017
+ /** min>0 时中途输入会被 snap 打断(如想输入 16 会先变成 10),仅失焦/Enter 提交 */
1018
+ if (min > 0) {
1019
+ inp.addEventListener('blur', commitPlain);
1020
+ inp.addEventListener('keydown', (e) => {
1021
+ if ((e as KeyboardEvent).key === 'Enter') inp.blur();
1022
+ });
1023
+ } else {
1024
+ inp.addEventListener('input', () => {
1025
+ const t = inp.value.trim();
1026
+ if (t === '') return;
1027
+ const v = Number(t);
1028
+ if (Number.isNaN(v)) return;
1029
+ onChange(snap(v));
1030
+ });
1031
+ inp.addEventListener('blur', commitPlain);
1032
+ inp.addEventListener('keydown', (e) => {
1033
+ if ((e as KeyboardEvent).key === 'Enter') commitPlain();
1034
+ });
1035
+ }
1036
+
889
1037
  return h('div', { class: 'sm-field' }, [
890
1038
  h('label', { class: 'sm-field__label' }, [label]),
891
- h('input', {
892
- class: 'sm-input',
893
- type: 'number',
894
- value: String(value),
895
- min,
896
- max,
897
- step,
898
- ...(focusToken ? { 'data-sm-focus': focusToken } : {}),
899
- oninput: (e: Event) => {
900
- const v = Number((e.target as HTMLInputElement).value);
901
- if (!Number.isNaN(v)) onChange(v);
902
- },
903
- }),
1039
+ inp,
904
1040
  ]);
905
1041
  }
906
1042
 
@@ -911,28 +1047,54 @@ export class RightPanel {
911
1047
  max,
912
1048
  step,
913
1049
  value: String(v0),
914
- oninput: (e: Event) => {
915
- const s = snap(Number((e.target as HTMLInputElement).value));
916
- num.value = String(s);
917
- onChange(s);
918
- },
919
- });
1050
+ }) as HTMLInputElement;
1051
+
920
1052
  const num = h('input', {
921
1053
  class: 'sm-input',
922
1054
  type: 'number',
923
- value: String(v0),
924
1055
  min,
925
1056
  max,
926
1057
  step,
1058
+ value: String(v0),
927
1059
  ...(focusToken ? { 'data-sm-focus': focusToken } : {}),
928
- oninput: (e: Event) => {
929
- const raw = Number((e.target as HTMLInputElement).value);
930
- if (Number.isNaN(raw)) return;
931
- const s = snap(raw);
932
- range.value = String(s);
933
- (e.target as HTMLInputElement).value = String(s);
934
- onChange(s);
935
- },
1060
+ }) as HTMLInputElement;
1061
+
1062
+ const syncFromSlider = (n: number) => {
1063
+ const s = snap(n);
1064
+ range.value = String(s);
1065
+ num.value = String(s);
1066
+ onChange(s);
1067
+ };
1068
+
1069
+ range.addEventListener('input', () => {
1070
+ syncFromSlider(Number(range.value));
1071
+ });
1072
+
1073
+ num.addEventListener('input', () => {
1074
+ const t = num.value.trim();
1075
+ if (t === '') return;
1076
+ const raw = Number(t);
1077
+ if (Number.isNaN(raw)) return;
1078
+ range.value = String(snap(raw));
1079
+ });
1080
+
1081
+ num.addEventListener('blur', () => {
1082
+ const t = num.value.trim();
1083
+ const fb = snap(Number(range.value));
1084
+ if (t === '') {
1085
+ syncFromSlider(fb);
1086
+ return;
1087
+ }
1088
+ const raw = Number(t);
1089
+ if (Number.isNaN(raw)) {
1090
+ syncFromSlider(fb);
1091
+ return;
1092
+ }
1093
+ syncFromSlider(raw);
1094
+ });
1095
+
1096
+ num.addEventListener('keydown', (e) => {
1097
+ if ((e as KeyboardEvent).key === 'Enter') num.blur();
936
1098
  });
937
1099
 
938
1100
  return h('div', { class: 'sm-field' }, [
@@ -945,27 +1107,107 @@ export class RightPanel {
945
1107
  value: string,
946
1108
  onChange: (v: string) => void,
947
1109
  focusPrefix?: string,
1110
+ placeholder = '#ffffff',
948
1111
  ) {
949
1112
  const pickToken = focusPrefix ? `${focusPrefix}:pick` : undefined;
950
1113
  const textToken = focusPrefix ? `${focusPrefix}:text` : undefined;
1114
+ const pick = h('input', {
1115
+ type: 'color',
1116
+ ...(pickToken ? { 'data-sm-focus': pickToken } : {}),
1117
+ }) as HTMLInputElement;
1118
+ const text = h('input', {
1119
+ class: 'sm-input',
1120
+ type: 'text',
1121
+ placeholder,
1122
+ ...(textToken ? { 'data-sm-focus': textToken } : {}),
1123
+ }) as HTMLInputElement;
1124
+
1125
+ let lastValid = value.trim();
1126
+
1127
+ const applyPickInherit = () => {
1128
+ pick.value = '#808080';
1129
+ pick.classList.add('sm-color-row__pick--inherit');
1130
+ pick.title = placeholder || '未设置';
1131
+ };
1132
+
1133
+ const applyStored = (stored: string) => {
1134
+ const trimmed = stored.trim();
1135
+ text.value = trimmed;
1136
+ const hex = normalizeAccentHex(trimmed);
1137
+ if (hex) {
1138
+ pick.value = hex;
1139
+ pick.classList.remove('sm-color-row__pick--inherit');
1140
+ pick.title = '';
1141
+ } else {
1142
+ applyPickInherit();
1143
+ }
1144
+ };
1145
+
1146
+ applyStored(value);
1147
+
1148
+ const previewPick = (hex: string) => {
1149
+ text.value = hex;
1150
+ pick.value = hex;
1151
+ pick.classList.remove('sm-color-row__pick--inherit');
1152
+ pick.title = '';
1153
+ lastValid = hex;
1154
+ };
1155
+
1156
+ const commitPick = (hex: string) => {
1157
+ previewPick(hex);
1158
+ onChange(hex);
1159
+ };
1160
+
1161
+ bindColorPickerInput(pick, {
1162
+ getValue: () => text.value.trim() || pick.value,
1163
+ placeholder,
1164
+ allowClear: true,
1165
+ onPreview: previewPick,
1166
+ onCommit: commitPick,
1167
+ onClear: () => {
1168
+ applyStored('');
1169
+ lastValid = '';
1170
+ onChange('');
1171
+ },
1172
+ });
1173
+
1174
+ text.addEventListener('input', () => {
1175
+ const raw = text.value;
1176
+ const hex = normalizeAccentHex(raw);
1177
+ if (hex) {
1178
+ pick.value = hex;
1179
+ pick.classList.remove('sm-color-row__pick--inherit');
1180
+ pick.title = '';
1181
+ lastValid = hex;
1182
+ } else if (!raw.trim()) {
1183
+ applyPickInherit();
1184
+ lastValid = '';
1185
+ }
1186
+ onChange(raw);
1187
+ });
1188
+
1189
+ text.addEventListener('blur', () => {
1190
+ const raw = text.value.trim();
1191
+ if (!raw) {
1192
+ applyStored('');
1193
+ lastValid = '';
1194
+ onChange('');
1195
+ return;
1196
+ }
1197
+ const hex = normalizeAccentHex(raw);
1198
+ if (hex) {
1199
+ applyStored(hex);
1200
+ lastValid = hex;
1201
+ if (hex !== raw) onChange(hex);
1202
+ return;
1203
+ }
1204
+ applyStored(lastValid);
1205
+ onChange(lastValid);
1206
+ });
1207
+
951
1208
  return h('div', { class: 'sm-field' }, [
952
1209
  h('label', { class: 'sm-field__label' }, [label]),
953
- h('div', { class: 'sm-color-row' }, [
954
- h('input', {
955
- type: 'color',
956
- value: value || '#ffffff',
957
- ...(pickToken ? { 'data-sm-focus': pickToken } : {}),
958
- oninput: (e: Event) => onChange((e.target as HTMLInputElement).value),
959
- }),
960
- h('input', {
961
- class: 'sm-input',
962
- type: 'text',
963
- value,
964
- placeholder: '#ffffff',
965
- ...(textToken ? { 'data-sm-focus': textToken } : {}),
966
- oninput: (e: Event) => onChange((e.target as HTMLInputElement).value),
967
- }),
968
- ]),
1210
+ h('div', { class: 'sm-color-row' }, [pick, text]),
969
1211
  ]);
970
1212
  }
971
1213
  private _selectField(
@@ -992,6 +1234,43 @@ export class RightPanel {
992
1234
  sel,
993
1235
  ]);
994
1236
  }
1237
+
1238
+ /** select 平铺:与顶栏 sm-segmented 视觉一致,占满右栏宽度 */
1239
+ private _segmentedSelectField(
1240
+ label: string,
1241
+ value: string,
1242
+ options: { label: string; value: string }[],
1243
+ onChange: (v: string) => void,
1244
+ focusToken?: string,
1245
+ wrap = false,
1246
+ ) {
1247
+ const group = h('div', {
1248
+ class: `sm-segmented sm-segmented--fill${wrap ? ' sm-segmented--wrap' : ''}`,
1249
+ role: 'group',
1250
+ 'aria-label': label,
1251
+ });
1252
+ for (const o of options) {
1253
+ const active = o.value === value;
1254
+ group.append(
1255
+ h(
1256
+ 'button',
1257
+ {
1258
+ type: 'button',
1259
+ class: `sm-segmented__item${active ? ' sm-segmented__item--active' : ''}`,
1260
+ 'aria-pressed': active ? 'true' : 'false',
1261
+ title: o.label,
1262
+ ...(focusToken ? { 'data-sm-focus': `${focusToken}:${o.value}` } : {}),
1263
+ onclick: () => onChange(o.value),
1264
+ },
1265
+ [o.label],
1266
+ ),
1267
+ );
1268
+ }
1269
+ return h('div', { class: 'sm-field' }, [
1270
+ h('label', { class: 'sm-field__label' }, [label]),
1271
+ group,
1272
+ ]);
1273
+ }
995
1274
  private _switchField(
996
1275
  label: string,
997
1276
  value: boolean,
@@ -1036,8 +1315,8 @@ export class RightPanel {
1036
1315
  ];
1037
1316
  const emit = () => onChange([...current]);
1038
1317
 
1039
- const makePlain = (v: number | undefined, idx: number) =>
1040
- h('input', {
1318
+ const makePlain = (v: number | undefined, idx: number) => {
1319
+ const inp = h('input', {
1041
1320
  class: 'sm-input',
1042
1321
  type: 'number',
1043
1322
  min: 0,
@@ -1045,12 +1324,32 @@ export class RightPanel {
1045
1324
  value: String(v ?? 0),
1046
1325
  title: titles[idx],
1047
1326
  ...(focusPrefix ? { 'data-sm-focus': `${focusPrefix}:${idx}` } : {}),
1048
- oninput: (e: Event) => {
1049
- const raw = Number((e.target as HTMLInputElement).value);
1050
- current[idx] = clampPad(Number.isNaN(raw) ? 0 : raw);
1051
- emit();
1052
- },
1327
+ }) as HTMLInputElement;
1328
+ inp.addEventListener('input', () => {
1329
+ const t = inp.value.trim();
1330
+ if (t === '') return;
1331
+ const raw = Number(t);
1332
+ if (Number.isNaN(raw)) return;
1333
+ current[idx] = clampPad(raw);
1334
+ emit();
1335
+ });
1336
+ inp.addEventListener('blur', () => {
1337
+ const t = inp.value.trim();
1338
+ if (t === '') {
1339
+ inp.value = String(current[idx]);
1340
+ return;
1341
+ }
1342
+ const raw = Number(t);
1343
+ const c = clampPad(Number.isNaN(raw) ? current[idx] : raw);
1344
+ inp.value = String(c);
1345
+ current[idx] = c;
1346
+ emit();
1347
+ });
1348
+ inp.addEventListener('keydown', (e) => {
1349
+ if ((e as KeyboardEvent).key === 'Enter') inp.blur();
1053
1350
  });
1351
+ return inp;
1352
+ };
1054
1353
 
1055
1354
  const makeSliderRow = (v: number | undefined, idx: number) => {
1056
1355
  const cur = clampPad(v ?? 0);
@@ -1061,13 +1360,15 @@ export class RightPanel {
1061
1360
  step: 1,
1062
1361
  value: String(cur),
1063
1362
  title: titles[idx],
1064
- oninput: (e: Event) => {
1065
- const c = clampPad(Number((e.target as HTMLInputElement).value));
1066
- current[idx] = c;
1067
- num.value = String(c);
1068
- emit();
1069
- },
1363
+ }) as HTMLInputElement;
1364
+
1365
+ range.addEventListener('input', () => {
1366
+ const c = clampPad(Number(range.value));
1367
+ current[idx] = c;
1368
+ num.value = String(c);
1369
+ emit();
1070
1370
  });
1371
+
1071
1372
  const num = h('input', {
1072
1373
  class: 'sm-input',
1073
1374
  type: 'number',
@@ -1076,16 +1377,38 @@ export class RightPanel {
1076
1377
  value: String(cur),
1077
1378
  title: titles[idx],
1078
1379
  ...(focusPrefix ? { 'data-sm-focus': `${focusPrefix}:${idx}` } : {}),
1079
- oninput: (e: Event) => {
1080
- const rawN = Number((e.target as HTMLInputElement).value);
1081
- if (Number.isNaN(rawN)) return;
1082
- const c = clampPad(rawN);
1083
- current[idx] = c;
1084
- range.value = String(c);
1085
- (e.target as HTMLInputElement).value = String(c);
1086
- emit();
1087
- },
1380
+ }) as HTMLInputElement;
1381
+
1382
+ num.addEventListener('input', () => {
1383
+ const t = num.value.trim();
1384
+ if (t === '') return;
1385
+ const rawN = Number(t);
1386
+ if (Number.isNaN(rawN)) return;
1387
+ const c = clampPad(rawN);
1388
+ range.value = String(c);
1389
+ current[idx] = c;
1390
+ emit();
1088
1391
  });
1392
+
1393
+ num.addEventListener('blur', () => {
1394
+ const t = num.value.trim();
1395
+ if (t === '') {
1396
+ num.value = String(current[idx]);
1397
+ range.value = String(current[idx]);
1398
+ return;
1399
+ }
1400
+ const rawN = Number(t);
1401
+ const c = clampPad(Number.isNaN(rawN) ? current[idx] : rawN);
1402
+ num.value = String(c);
1403
+ range.value = String(c);
1404
+ current[idx] = c;
1405
+ emit();
1406
+ });
1407
+
1408
+ num.addEventListener('keydown', (e) => {
1409
+ if ((e as KeyboardEvent).key === 'Enter') num.blur();
1410
+ });
1411
+
1089
1412
  return h('div', { class: 'sm-spacing-row' }, [
1090
1413
  h('span', { class: 'sm-spacing-row__axis' }, [titles[idx]]),
1091
1414
  range,
@@ -1126,6 +1449,188 @@ export class RightPanel {
1126
1449
  ]);
1127
1450
  }
1128
1451
 
1452
+ /** 「继承全局」开关行:开=跟随邮件全局,关=块级固定值 */
1453
+ private _globalInheritSwitch(
1454
+ inheritOn: boolean,
1455
+ globalDisplay: string,
1456
+ onToggle: (inherit: boolean) => void,
1457
+ focusToken?: string,
1458
+ ): { el: HTMLElement; setInherit: (on: boolean, display?: string) => void } {
1459
+ const input = h('input', {
1460
+ type: 'checkbox',
1461
+ class: 'sm-inherit-switch__input',
1462
+ role: 'switch',
1463
+ ...(focusToken ? { 'data-sm-focus': focusToken } : {}),
1464
+ ...(inheritOn ? { checked: true } : {}),
1465
+ }) as HTMLInputElement;
1466
+ const valueEl = h('span', {
1467
+ class: 'sm-inherit-switch__value',
1468
+ hidden: !inheritOn,
1469
+ }, [globalDisplay]);
1470
+ const el = h('div', { class: 'sm-inherit-switch' }, [
1471
+ h('label', { class: 'sm-inherit-switch__label' }, [
1472
+ input,
1473
+ h('span', { class: 'sm-inherit-switch__text' }, ['继承全局']),
1474
+ ]),
1475
+ valueEl,
1476
+ ]);
1477
+ input.addEventListener('change', () => onToggle(input.checked));
1478
+ const setInherit = (on: boolean, display?: string) => {
1479
+ input.checked = on;
1480
+ if (display !== undefined) valueEl.textContent = display;
1481
+ valueEl.hidden = !on;
1482
+ };
1483
+ return { el, setInherit };
1484
+ }
1485
+
1486
+ private _fontWeightInheritField(
1487
+ label: string,
1488
+ stored: string,
1489
+ globalWeight: string,
1490
+ onChange: (v: string) => void,
1491
+ focusToken: string,
1492
+ options: { label: string; value: string }[],
1493
+ help?: string,
1494
+ ): HTMLElement {
1495
+ let inheriting = !stored;
1496
+ const group = h('div', {
1497
+ class: 'sm-segmented sm-segmented--fill',
1498
+ role: 'group',
1499
+ 'aria-label': label,
1500
+ });
1501
+
1502
+ const syncGroup = () => {
1503
+ group.classList.toggle('sm-segmented--disabled', inheriting);
1504
+ const display = inheriting ? '' : normalizeFontWeightStep(stored || globalWeight);
1505
+ clear(group);
1506
+ for (const o of options) {
1507
+ const active = !inheriting && o.value === display;
1508
+ group.append(
1509
+ h(
1510
+ 'button',
1511
+ {
1512
+ type: 'button',
1513
+ class: `sm-segmented__item${active ? ' sm-segmented__item--active' : ''}`,
1514
+ 'aria-pressed': active ? 'true' : 'false',
1515
+ title: o.label,
1516
+ ...(focusToken ? { 'data-sm-focus': `${focusToken}:${o.value}` } : {}),
1517
+ onclick: () => {
1518
+ inheriting = false;
1519
+ stored = o.value;
1520
+ inheritSwitch.setInherit(false);
1521
+ syncGroup();
1522
+ onChange(o.value);
1523
+ },
1524
+ },
1525
+ [o.label],
1526
+ ),
1527
+ );
1528
+ }
1529
+ };
1530
+
1531
+ const inheritSwitch = this._globalInheritSwitch(
1532
+ inheriting,
1533
+ globalWeight,
1534
+ (on) => {
1535
+ if (on) {
1536
+ inheriting = true;
1537
+ stored = '';
1538
+ inheritSwitch.setInherit(true, globalWeight);
1539
+ syncGroup();
1540
+ onChange('');
1541
+ return;
1542
+ }
1543
+ inheriting = false;
1544
+ stored = globalWeight;
1545
+ inheritSwitch.setInherit(false);
1546
+ syncGroup();
1547
+ onChange(globalWeight);
1548
+ },
1549
+ `${focusToken}:inherit`,
1550
+ );
1551
+
1552
+ syncGroup();
1553
+
1554
+ const wrap = h('div', { class: 'sm-field' }, [
1555
+ h('label', { class: 'sm-field__label' }, [label]),
1556
+ inheritSwitch.el,
1557
+ group,
1558
+ ]);
1559
+ if (help) wrap.append(h('div', { class: 'sm-field__help' }, [help]));
1560
+ return wrap;
1561
+ }
1562
+
1563
+ private _fontSizeInheritTextField(
1564
+ label: string,
1565
+ stored: string,
1566
+ onChange: (v: string) => void,
1567
+ focusToken: string,
1568
+ globalFontSize: string,
1569
+ help?: string,
1570
+ ): HTMLElement {
1571
+ const globalPx = this._snapFontSizeToPx(globalFontSize);
1572
+ let inheriting = !stored.trim();
1573
+ const input = h('input', {
1574
+ class: 'sm-input',
1575
+ type: 'text',
1576
+ ...(focusToken ? { 'data-sm-focus': focusToken } : {}),
1577
+ }) as HTMLInputElement;
1578
+
1579
+ const syncInput = () => {
1580
+ input.disabled = inheriting;
1581
+ input.value = inheriting ? '' : stored;
1582
+ input.placeholder = inheriting ? '' : '如 16px';
1583
+ input.classList.toggle('sm-input--inherit-disabled', inheriting);
1584
+ };
1585
+
1586
+ const inheritSwitch = this._globalInheritSwitch(
1587
+ inheriting,
1588
+ `${globalPx}px`,
1589
+ (on) => {
1590
+ if (on) {
1591
+ inheriting = true;
1592
+ stored = '';
1593
+ inheritSwitch.setInherit(true, `${globalPx}px`);
1594
+ syncInput();
1595
+ onChange('');
1596
+ return;
1597
+ }
1598
+ inheriting = false;
1599
+ stored = `${globalPx}px`;
1600
+ inheritSwitch.setInherit(false);
1601
+ syncInput();
1602
+ onChange(stored);
1603
+ },
1604
+ `${focusToken}:inherit`,
1605
+ );
1606
+
1607
+ syncInput();
1608
+
1609
+ input.addEventListener('input', () => {
1610
+ if (inheriting) return;
1611
+ stored = input.value;
1612
+ onChange(stored);
1613
+ });
1614
+
1615
+ input.addEventListener('blur', () => {
1616
+ if (inheriting) return;
1617
+ const t = input.value.trim();
1618
+ if (!t) {
1619
+ stored = `${globalPx}px`;
1620
+ input.value = stored;
1621
+ onChange(stored);
1622
+ }
1623
+ });
1624
+
1625
+ const wrap = h('div', { class: 'sm-field' }, [
1626
+ h('label', { class: 'sm-field__label' }, [label]),
1627
+ inheritSwitch.el,
1628
+ input,
1629
+ ]);
1630
+ if (help) wrap.append(h('div', { class: 'sm-field__help' }, [help]));
1631
+ return wrap;
1632
+ }
1633
+
1129
1634
  private _snapFontSizeToPx(value: string): number {
1130
1635
  const t = (value ?? '').trim();
1131
1636
  const m = t.match(/^(\d+(\.\d+)?)/);
@@ -1139,49 +1644,128 @@ export class RightPanel {
1139
1644
  onChange: (v: string) => void,
1140
1645
  focusToken: string,
1141
1646
  help?: string,
1647
+ globalFontSize?: string,
1142
1648
  ) {
1649
+ const inheritGlobal = globalFontSize !== undefined;
1143
1650
  if (!this._preferSliderControls()) {
1651
+ if (inheritGlobal) {
1652
+ return this._fontSizeInheritTextField(
1653
+ label,
1654
+ value,
1655
+ onChange,
1656
+ focusToken,
1657
+ globalFontSize,
1658
+ help,
1659
+ );
1660
+ }
1144
1661
  return this._textField(label, value ?? '', onChange, '', focusToken);
1145
1662
  }
1663
+
1146
1664
  const min = 10;
1147
1665
  const max = 48;
1148
1666
  const step = 1;
1149
1667
  const snap = (n: number) => Math.min(max, Math.max(min, Math.round(n)));
1150
- const px = snap(this._snapFontSizeToPx(value ?? ''));
1668
+ const globalPx = snap(this._snapFontSizeToPx(globalFontSize ?? value ?? '16px'));
1669
+ let stored = value.trim();
1670
+ let inheriting = inheritGlobal && !stored;
1151
1671
 
1672
+ const sliderRow = h('div', { class: 'sm-field__slider-row' });
1152
1673
  const range = h('input', {
1153
1674
  type: 'range',
1154
1675
  min,
1155
1676
  max,
1156
1677
  step,
1157
- value: String(px),
1158
- oninput: (e: Event) => {
1159
- const s = snap(Number((e.target as HTMLInputElement).value));
1160
- num.value = String(s);
1161
- onChange(`${s}px`);
1162
- },
1163
- });
1678
+ }) as HTMLInputElement;
1164
1679
  const num = h('input', {
1165
1680
  class: 'sm-input',
1166
1681
  type: 'number',
1167
1682
  min,
1168
1683
  max,
1169
1684
  step,
1170
- value: String(px),
1171
1685
  'data-sm-focus': focusToken,
1172
- oninput: (e: Event) => {
1173
- const rawN = Number((e.target as HTMLInputElement).value);
1174
- if (Number.isNaN(rawN)) return;
1175
- const s = snap(rawN);
1686
+ }) as HTMLInputElement;
1687
+ sliderRow.append(range, num);
1688
+
1689
+ const syncSlider = () => {
1690
+ sliderRow.classList.toggle('sm-field__slider-row--inherit', inheriting);
1691
+ num.disabled = inheriting;
1692
+ if (inheriting) {
1693
+ range.value = String(globalPx);
1694
+ num.value = '';
1695
+ } else {
1696
+ const s = snap(this._snapFontSizeToPx(stored || `${globalPx}px`));
1176
1697
  range.value = String(s);
1177
- (e.target as HTMLInputElement).value = String(s);
1178
- onChange(`${s}px`);
1179
- },
1698
+ num.value = String(s);
1699
+ }
1700
+ };
1701
+
1702
+ let inheritSwitch: ReturnType<RightPanel['_globalInheritSwitch']> | null = null;
1703
+ if (inheritGlobal) {
1704
+ inheritSwitch = this._globalInheritSwitch(
1705
+ inheriting,
1706
+ `${globalPx}px`,
1707
+ (on) => {
1708
+ if (on) {
1709
+ inheriting = true;
1710
+ stored = '';
1711
+ inheritSwitch!.setInherit(true, `${globalPx}px`);
1712
+ syncSlider();
1713
+ onChange('');
1714
+ return;
1715
+ }
1716
+ inheriting = false;
1717
+ stored = `${globalPx}px`;
1718
+ inheritSwitch!.setInherit(false);
1719
+ syncSlider();
1720
+ onChange(stored);
1721
+ },
1722
+ `${focusToken}:inherit`,
1723
+ );
1724
+ }
1725
+
1726
+ syncSlider();
1727
+
1728
+ const leaveInherit = () => {
1729
+ if (!inheriting) return;
1730
+ inheriting = false;
1731
+ inheritSwitch?.setInherit(false);
1732
+ syncSlider();
1733
+ };
1734
+
1735
+ range.addEventListener('input', () => {
1736
+ leaveInherit();
1737
+ const s = snap(Number(range.value));
1738
+ num.value = String(s);
1739
+ stored = `${s}px`;
1740
+ onChange(stored);
1741
+ });
1742
+
1743
+ num.addEventListener('input', () => {
1744
+ if (inheriting) return;
1745
+ const rawN = Number(num.value);
1746
+ if (Number.isNaN(rawN)) return;
1747
+ range.value = String(snap(rawN));
1748
+ });
1749
+
1750
+ num.addEventListener('blur', () => {
1751
+ if (inheriting) return;
1752
+ const t = num.value.trim();
1753
+ const fb = snap(Number(range.value));
1754
+ const s = snap(t === '' || Number.isNaN(Number(t)) ? fb : Number(t));
1755
+ stored = `${s}px`;
1756
+ num.value = String(s);
1757
+ range.value = String(s);
1758
+ onChange(stored);
1759
+ });
1760
+
1761
+ num.addEventListener('keydown', (e) => {
1762
+ if ((e as KeyboardEvent).key === 'Enter') num.blur();
1180
1763
  });
1181
1764
 
1182
1765
  const wrap = h('div', { class: 'sm-field' }, [
1183
1766
  h('label', { class: 'sm-field__label' }, [label]),
1184
- h('div', { class: 'sm-field__slider-row' }, [range, num]),
1767
+ ...(inheritSwitch ? [inheritSwitch.el] : []),
1768
+ sliderRow,
1185
1769
  ]);
1186
1770
  if (help) wrap.append(h('div', { class: 'sm-field__help' }, [help]));
1187
1771
  return wrap;
@@ -1238,6 +1822,8 @@ export class RightPanel {
1238
1822
  const min = 1;
1239
1823
  const displayN = mode === 'auto' ? 280 : layoutWidthNumeric(raw, mode === '%' ? '%' : 'px');
1240
1824
 
1825
+ const clampW = (n: number) => Math.max(min, Math.min(max, Math.round(n)));
1826
+
1241
1827
  const range = h('input', {
1242
1828
  type: 'range',
1243
1829
  min,
@@ -1245,13 +1831,8 @@ export class RightPanel {
1245
1831
  step: 1,
1246
1832
  value: String(displayN),
1247
1833
  disabled: mode === 'auto',
1248
- oninput: (e: Event) => {
1249
- if (mode === 'auto') return;
1250
- const c = Math.max(min, Math.min(max, Math.round(Number((e.target as HTMLInputElement).value))));
1251
- num.value = String(c);
1252
- onChange(mode === '%' ? `${c}%` : `${c}px`);
1253
- },
1254
- });
1834
+ }) as HTMLInputElement;
1835
+
1255
1836
  const num = h('input', {
1256
1837
  class: 'sm-input',
1257
1838
  type: 'number',
@@ -1261,15 +1842,44 @@ export class RightPanel {
1261
1842
  value: String(displayN),
1262
1843
  disabled: mode === 'auto',
1263
1844
  ...(focusToken ? { 'data-sm-focus': focusToken } : {}),
1264
- oninput: (e: Event) => {
1265
- if (mode === 'auto') return;
1266
- const rawN = Number((e.target as HTMLInputElement).value);
1267
- if (Number.isNaN(rawN)) return;
1268
- const c = Math.max(min, Math.min(max, Math.round(rawN)));
1269
- range.value = String(c);
1270
- (e.target as HTMLInputElement).value = String(c);
1271
- onChange(mode === '%' ? `${c}%` : `${c}px`);
1272
- },
1845
+ }) as HTMLInputElement;
1846
+
1847
+ const emitW = (c: number) => {
1848
+ const v = clampW(c);
1849
+ range.value = String(v);
1850
+ num.value = String(v);
1851
+ onChange(mode === '%' ? `${v}%` : `${v}px`);
1852
+ };
1853
+
1854
+ range.addEventListener('input', () => {
1855
+ if (mode === 'auto') return;
1856
+ emitW(Number(range.value));
1857
+ });
1858
+
1859
+ num.addEventListener('input', () => {
1860
+ if (mode === 'auto') return;
1861
+ const t = num.value.trim();
1862
+ if (t === '') return;
1863
+ const rawN = Number(t);
1864
+ if (Number.isNaN(rawN)) return;
1865
+ range.value = String(clampW(rawN));
1866
+ });
1867
+
1868
+ num.addEventListener('blur', () => {
1869
+ if (mode === 'auto') return;
1870
+ const t = num.value.trim();
1871
+ const fb = clampW(Number(range.value));
1872
+ if (t === '') {
1873
+ emitW(fb);
1874
+ return;
1875
+ }
1876
+ const rawN = Number(t);
1877
+ emitW(Number.isNaN(rawN) ? fb : rawN);
1878
+ });
1879
+
1880
+ num.addEventListener('keydown', (e) => {
1881
+ if (mode === 'auto') return;
1882
+ if ((e as KeyboardEvent).key === 'Enter') num.blur();
1273
1883
  });
1274
1884
 
1275
1885
  const sliderRow = h(
@@ -1310,34 +1920,26 @@ export class RightPanel {
1310
1920
  }
1311
1921
 
1312
1922
  private _docFontWeightField(value: string, onChange: (v: string) => void, focusToken: string) {
1313
- const opts = [
1314
- { label: '常规', value: 'normal' },
1315
- { label: '中等 500', value: '500' },
1316
- { label: '半粗 600', value: '600' },
1317
- { label: '加粗 700', value: 'bold' },
1318
- ];
1319
- if (!this._preferSliderControls()) {
1320
- return this._selectField('字重', value ?? 'normal', opts, onChange, focusToken);
1321
- }
1322
- const cur = value || 'normal';
1323
- const row = h('div', { class: 'sm-segmented sm-segmented--wrap' });
1324
- for (const o of opts) {
1325
- row.append(
1326
- h(
1327
- 'button',
1328
- {
1329
- type: 'button',
1330
- class: `sm-segmented__item ${o.value === cur ? 'sm-segmented__item--active' : ''}`,
1331
- onclick: () => onChange(o.value),
1332
- },
1333
- [o.label],
1334
- ),
1335
- );
1336
- }
1337
- return h('div', { class: 'sm-field' }, [
1338
- h('label', { class: 'sm-field__label' }, ['字重']),
1339
- row,
1340
- ]);
1923
+ const cur = normalizeFontWeightStep(value);
1924
+ return this._segmentedSelectField('字重', cur, FONT_WEIGHT_STEP_OPTIONS, onChange, focusToken);
1925
+ }
1926
+
1927
+ private _twoColumnRatioField(
1928
+ layout: SectionLayout,
1929
+ onChange: (layout: SectionLayout) => void,
1930
+ focusToken: string,
1931
+ ) {
1932
+ return this._segmentedSelectField(
1933
+ '列比例',
1934
+ layout,
1935
+ [
1936
+ { label: '1:1', value: '1-1' },
1937
+ { label: '1:2', value: '1-2' },
1938
+ { label: '2:1', value: '2-1' },
1939
+ ],
1940
+ (v) => onChange(v as SectionLayout),
1941
+ focusToken,
1942
+ );
1341
1943
  }
1342
1944
 
1343
1945
  private _renderBlockCodeView(block: Block): HTMLElement {