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.
@@ -2,19 +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';
16
26
  import { FONT_WEIGHT_STEP_OPTIONS, normalizeFontWeightStep } from '../utils/fontWeightSteps';
17
27
  import type { ImageAssetsHandlers, ImageFieldContext } from './imageAssets';
28
+ import { FocusBreadcrumb } from './FocusBreadcrumb';
18
29
  import { openImageGalleryModal } from './ImageGalleryModal';
19
30
 
20
31
  /** 与社交组 block 中每行元素结构一致(core 不依赖 blocks) */
@@ -48,6 +59,12 @@ function layoutWidthNumeric(raw: string, mode: 'px' | '%'): number {
48
59
  export interface RightPanelOptions {
49
60
  store: Store;
50
61
  registry: Registry;
62
+ /** 聚焦路径根节点文案(文档级),默认「邮件」 */
63
+ docRootLabel?: string;
64
+ /** 点击路径中的 Section 层级 */
65
+ onFocusSection: (sectionId: string) => void;
66
+ /** 点击路径中的文档级层级(等同 Esc 从 Section 上浮) */
67
+ onFocusDocument: () => void;
51
68
  /**
52
69
  * 可选。配置后,schema 中 `type: 'image'` 的字段会显示「上传 / 图床」按钮并调用对应回调。
53
70
  */
@@ -65,6 +82,9 @@ type RightTab = 'props' | 'code';
65
82
  export class RightPanel {
66
83
  el: HTMLElement;
67
84
  private opts: RightPanelOptions;
85
+ private headEl: HTMLElement;
86
+ private contentEl: HTMLElement;
87
+ private focusCrumb: FocusBreadcrumb;
68
88
  private currentTab: RightTab = 'props';
69
89
  private codeView: EditorView | null = null;
70
90
 
@@ -79,6 +99,17 @@ export class RightPanel {
79
99
  constructor(opts: RightPanelOptions) {
80
100
  this.opts = opts;
81
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();
82
113
  this._render();
83
114
  opts.store.subscribe((detail?: DocChangedDetail) => {
84
115
  if (detail?.source === 'history') {
@@ -95,10 +126,17 @@ export class RightPanel {
95
126
  });
96
127
  opts.store.subscribeSelection(() => {
97
128
  this.currentTab = 'props';
129
+ this._syncHeadVisibility();
98
130
  this._render();
99
131
  });
100
132
  }
101
133
 
134
+ /** 文档级(无选中)时不展示面包屑,避免与「邮件设置」标题重复 */
135
+ private _syncHeadVisibility() {
136
+ const show = !!this.opts.store.selection;
137
+ this.headEl.hidden = !show;
138
+ }
139
+
102
140
  /** 焦点在右栏可编辑控件上时不要整栏重渲染(避免输入/CodeMirror 失焦) */
103
141
  private _shouldSkipRenderBecauseFocused(): boolean {
104
142
  const ae = document.activeElement as HTMLElement | null;
@@ -144,20 +182,20 @@ export class RightPanel {
144
182
 
145
183
  private _render() {
146
184
  this._destroyCodeView();
147
- clear(this.el);
185
+ clear(this.contentEl);
148
186
 
149
187
  const sel = this.opts.store.selection;
150
188
  const doc = this.opts.store.doc;
151
189
 
152
190
  if (!sel) {
153
- this.el.append(this._renderDocForm(doc));
191
+ this.contentEl.append(this._renderDocForm(doc));
154
192
  return;
155
193
  }
156
194
 
157
195
  if (sel.kind === 'section') {
158
196
  const section = findSection(doc, sel.sectionId);
159
197
  if (!section) return;
160
- this.el.append(this._renderSectionForm(section));
198
+ this.contentEl.append(this._renderSectionForm(section));
161
199
  return;
162
200
  }
163
201
 
@@ -168,8 +206,8 @@ export class RightPanel {
168
206
  this._tabBtn('props', '属性'),
169
207
  this._tabBtn('code', '代码'),
170
208
  ]);
171
- this.el.append(tabs);
172
- this.el.append(
209
+ this.contentEl.append(tabs);
210
+ this.contentEl.append(
173
211
  h('div', { class: 'sm-empty-form' }, ['该组件可能已被删除,请重新选中画布中的块。']),
174
212
  );
175
213
  return;
@@ -180,11 +218,11 @@ export class RightPanel {
180
218
  this._tabBtn('props', '属性'),
181
219
  this._tabBtn('code', '代码'),
182
220
  ]);
183
- this.el.append(tabs);
221
+ this.contentEl.append(tabs);
184
222
 
185
223
  if (this.currentTab === 'props') {
186
224
  if (loc.block.lockedMjml) {
187
- this.el.append(
225
+ this.contentEl.append(
188
226
  h('div', { class: 'sm-empty-form' }, [
189
227
  '该组件已被代码模式锁定。',
190
228
  h('div', { style: 'margin-top:8px;' }, [
@@ -206,14 +244,14 @@ export class RightPanel {
206
244
  ]),
207
245
  );
208
246
  } else if (def) {
209
- this.el.append(this._renderBlockForm(loc.block, def.schema));
247
+ this.contentEl.append(this._renderBlockForm(loc.block, def.schema));
210
248
  } else {
211
- this.el.append(
249
+ this.contentEl.append(
212
250
  h('div', { class: 'sm-empty-form' }, [`未注册的组件: ${loc.block.type}`]),
213
251
  );
214
252
  }
215
253
  } else {
216
- this.el.append(this._renderBlockCodeView(loc.block));
254
+ this.contentEl.append(this._renderBlockCodeView(loc.block));
217
255
  }
218
256
  }
219
257
 
@@ -352,6 +390,19 @@ export class RightPanel {
352
390
  const a = section.attrs;
353
391
  return h('form', { class: 'sm-form', onsubmit: (e: Event) => e.preventDefault() }, [
354
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
+ : []),
355
406
  this._colorField(
356
407
  '背景色',
357
408
  a.backgroundColor ?? '',
@@ -361,6 +412,7 @@ export class RightPanel {
361
412
  if (s) s.attrs.backgroundColor = v || undefined;
362
413
  }),
363
414
  `section:${section.id}:attrs.bg`,
415
+ '留空=透明',
364
416
  ),
365
417
  this._layoutWidthField(
366
418
  '区域宽度',
@@ -460,14 +512,45 @@ export class RightPanel {
460
512
  field.step ?? 1,
461
513
  fp,
462
514
  );
463
- case 'color':
464
- return this._colorField(field.label, String(value ?? ''), 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
+ }
465
527
  case 'select': {
466
528
  const isFw = field.key === 'fontWeight' || field.key === 'labelFontWeight';
467
- const displayVal = isFw ? normalizeFontWeightStep(String(value ?? '')) : String(value ?? '');
468
- const opts = isFw ? FONT_WEIGHT_STEP_OPTIONS : (field.options ?? []);
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 ?? '');
469
550
  if (isFw || field.selectVariant === 'segmented') {
470
- return this._segmentedSelectField(field.label, displayVal, opts, onChange, fp);
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;
471
554
  }
472
555
  return this._selectField(field.label, displayVal, opts, onChange, fp);
473
556
  }
@@ -485,30 +568,25 @@ export class RightPanel {
485
568
  case 'url':
486
569
  case 'text': {
487
570
  if (field.key === 'width' && this._preferSliderControls()) {
488
- const el = this._layoutWidthField(
571
+ return this._layoutWidthField(
489
572
  field.label,
490
573
  String(value ?? ''),
491
574
  (v) => onChange(v),
492
575
  fp,
493
576
  field.help,
494
577
  );
495
- if (field.placeholder && !field.help) {
496
- el.append(h('div', { class: 'sm-field__help' }, [field.placeholder]));
497
- }
498
- return el;
499
578
  }
500
- if (field.key === 'fontSize' && this._preferSliderControls()) {
501
- 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(
502
583
  field.label,
503
- String(value ?? ''),
504
- (v) => onChange(v),
584
+ rawFs,
585
+ (v) => onChange(v.trim()),
505
586
  fp,
506
587
  field.help,
588
+ this.opts.store.doc.styles.fontSize,
507
589
  );
508
- if (field.placeholder && !field.help) {
509
- el.append(h('div', { class: 'sm-field__help' }, [field.placeholder]));
510
- }
511
- return el;
512
590
  }
513
591
  const row = this._textField(
514
592
  field.label,
@@ -677,16 +755,18 @@ export class RightPanel {
677
755
  ...(focusPrefix ? { 'data-sm-focus': `${focusPrefix}:row:${i}:icon` } : {}),
678
756
  oninput: (e: Event) => syncItem(i, { iconSrc: (e.target as HTMLInputElement).value }),
679
757
  }),
680
- h('input', {
681
- class: 'sm-social-link-list__color sm-social-link-list__color--compact',
682
- type: 'color',
683
- title: '图标背景色',
684
- value: bgDisplay.length >= 4 ? bgDisplay : bgResolved,
685
- ...(focusPrefix ? { 'data-sm-focus': `${focusPrefix}:row:${i}:bg` } : {}),
686
- oninput: (e: Event) =>
687
- syncItem(i, { backgroundColor: (e.target as HTMLInputElement).value }),
688
- }),
689
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);
690
770
  rowWrap.append(extras);
691
771
 
692
772
  listEl.append(rowWrap);
@@ -1027,27 +1107,107 @@ export class RightPanel {
1027
1107
  value: string,
1028
1108
  onChange: (v: string) => void,
1029
1109
  focusPrefix?: string,
1110
+ placeholder = '#ffffff',
1030
1111
  ) {
1031
1112
  const pickToken = focusPrefix ? `${focusPrefix}:pick` : undefined;
1032
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
+
1033
1208
  return h('div', { class: 'sm-field' }, [
1034
1209
  h('label', { class: 'sm-field__label' }, [label]),
1035
- h('div', { class: 'sm-color-row' }, [
1036
- h('input', {
1037
- type: 'color',
1038
- value: value || '#ffffff',
1039
- ...(pickToken ? { 'data-sm-focus': pickToken } : {}),
1040
- oninput: (e: Event) => onChange((e.target as HTMLInputElement).value),
1041
- }),
1042
- h('input', {
1043
- class: 'sm-input',
1044
- type: 'text',
1045
- value,
1046
- placeholder: '#ffffff',
1047
- ...(textToken ? { 'data-sm-focus': textToken } : {}),
1048
- oninput: (e: Event) => onChange((e.target as HTMLInputElement).value),
1049
- }),
1050
- ]),
1210
+ h('div', { class: 'sm-color-row' }, [pick, text]),
1051
1211
  ]);
1052
1212
  }
1053
1213
  private _selectField(
@@ -1082,9 +1242,10 @@ export class RightPanel {
1082
1242
  options: { label: string; value: string }[],
1083
1243
  onChange: (v: string) => void,
1084
1244
  focusToken?: string,
1245
+ wrap = false,
1085
1246
  ) {
1086
1247
  const group = h('div', {
1087
- class: 'sm-segmented sm-segmented--fill',
1248
+ class: `sm-segmented sm-segmented--fill${wrap ? ' sm-segmented--wrap' : ''}`,
1088
1249
  role: 'group',
1089
1250
  'aria-label': label,
1090
1251
  });
@@ -1288,6 +1449,188 @@ export class RightPanel {
1288
1449
  ]);
1289
1450
  }
1290
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
+
1291
1634
  private _snapFontSizeToPx(value: string): number {
1292
1635
  const t = (value ?? '').trim();
1293
1636
  const m = t.match(/^(\d+(\.\d+)?)/);
@@ -1301,63 +1644,118 @@ export class RightPanel {
1301
1644
  onChange: (v: string) => void,
1302
1645
  focusToken: string,
1303
1646
  help?: string,
1647
+ globalFontSize?: string,
1304
1648
  ) {
1649
+ const inheritGlobal = globalFontSize !== undefined;
1305
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
+ }
1306
1661
  return this._textField(label, value ?? '', onChange, '', focusToken);
1307
1662
  }
1663
+
1308
1664
  const min = 10;
1309
1665
  const max = 48;
1310
1666
  const step = 1;
1311
1667
  const snap = (n: number) => Math.min(max, Math.max(min, Math.round(n)));
1312
- 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;
1313
1671
 
1672
+ const sliderRow = h('div', { class: 'sm-field__slider-row' });
1314
1673
  const range = h('input', {
1315
1674
  type: 'range',
1316
1675
  min,
1317
1676
  max,
1318
1677
  step,
1319
- value: String(px),
1320
1678
  }) as HTMLInputElement;
1321
-
1322
1679
  const num = h('input', {
1323
1680
  class: 'sm-input',
1324
1681
  type: 'number',
1325
1682
  min,
1326
1683
  max,
1327
1684
  step,
1328
- value: String(px),
1329
1685
  'data-sm-focus': focusToken,
1330
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`));
1697
+ range.value = String(s);
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
+ };
1331
1734
 
1332
1735
  range.addEventListener('input', () => {
1736
+ leaveInherit();
1333
1737
  const s = snap(Number(range.value));
1334
1738
  num.value = String(s);
1335
- onChange(`${s}px`);
1739
+ stored = `${s}px`;
1740
+ onChange(stored);
1336
1741
  });
1337
1742
 
1338
1743
  num.addEventListener('input', () => {
1339
- const t = num.value.trim();
1340
- if (t === '') return;
1341
- const rawN = Number(t);
1744
+ if (inheriting) return;
1745
+ const rawN = Number(num.value);
1342
1746
  if (Number.isNaN(rawN)) return;
1343
1747
  range.value = String(snap(rawN));
1344
1748
  });
1345
1749
 
1346
1750
  num.addEventListener('blur', () => {
1751
+ if (inheriting) return;
1347
1752
  const t = num.value.trim();
1348
1753
  const fb = snap(Number(range.value));
1349
- if (t === '') {
1350
- const r = fb;
1351
- num.value = String(r);
1352
- range.value = String(r);
1353
- onChange(`${r}px`);
1354
- return;
1355
- }
1356
- const rawN = Number(t);
1357
- const s = snap(Number.isNaN(rawN) ? fb : rawN);
1754
+ const s = snap(t === '' || Number.isNaN(Number(t)) ? fb : Number(t));
1755
+ stored = `${s}px`;
1358
1756
  num.value = String(s);
1359
1757
  range.value = String(s);
1360
- onChange(`${s}px`);
1758
+ onChange(stored);
1361
1759
  });
1362
1760
 
1363
1761
  num.addEventListener('keydown', (e) => {
@@ -1366,7 +1764,8 @@ export class RightPanel {
1366
1764
 
1367
1765
  const wrap = h('div', { class: 'sm-field' }, [
1368
1766
  h('label', { class: 'sm-field__label' }, [label]),
1369
- h('div', { class: 'sm-field__slider-row' }, [range, num]),
1767
+ ...(inheritSwitch ? [inheritSwitch.el] : []),
1768
+ sliderRow,
1370
1769
  ]);
1371
1770
  if (help) wrap.append(h('div', { class: 'sm-field__help' }, [help]));
1372
1771
  return wrap;
@@ -1525,6 +1924,24 @@ export class RightPanel {
1525
1924
  return this._segmentedSelectField('字重', cur, FONT_WEIGHT_STEP_OPTIONS, onChange, focusToken);
1526
1925
  }
1527
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
+ );
1943
+ }
1944
+
1528
1945
  private _renderBlockCodeView(block: Block): HTMLElement {
1529
1946
  const def = this.opts.registry.get(block.type);
1530
1947
  const initial =