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.
- package/README.md +50 -1
- package/package.json +1 -1
- package/packages/blocks/dist/index.js +88 -89
- package/packages/blocks/dist/index.js.map +1 -1
- package/packages/blocks/src/text.ts +21 -14
- package/packages/core/dist/index.d.ts +45 -4
- package/packages/core/dist/index.js +2252 -1449
- package/packages/core/dist/index.js.map +1 -1
- package/packages/core/dist/style.css +1 -1
- package/packages/core/src/editor/Canvas.ts +68 -34
- package/packages/core/src/editor/ColorPickerPopover.ts +291 -0
- package/packages/core/src/editor/Editor.ts +72 -2
- package/packages/core/src/editor/ExportModal.ts +2 -1
- package/packages/core/src/editor/FocusBreadcrumb.ts +120 -0
- package/packages/core/src/editor/InlineEditor.ts +306 -65
- package/packages/core/src/editor/LeftPanel.ts +1 -2
- package/packages/core/src/editor/RichTextToolbar.ts +66 -44
- package/packages/core/src/editor/RightPanel.ts +491 -74
- package/packages/core/src/editor/SourceView.ts +2 -1
- package/packages/core/src/editor/Topbar.ts +98 -7
- package/packages/core/src/editor/styles.css +378 -25
- package/packages/core/src/index.ts +12 -1
- package/packages/core/src/store/store.ts +44 -3
- package/packages/core/src/types.ts +6 -0
- package/packages/core/src/utils/richTextCommand.ts +30 -0
- package/packages/core/src/utils/sectionLayout.ts +13 -0
|
@@ -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 {
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
172
|
-
this.
|
|
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.
|
|
221
|
+
this.contentEl.append(tabs);
|
|
184
222
|
|
|
185
223
|
if (this.currentTab === 'props') {
|
|
186
224
|
if (loc.block.lockedMjml) {
|
|
187
|
-
this.
|
|
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.
|
|
247
|
+
this.contentEl.append(this._renderBlockForm(loc.block, def.schema));
|
|
210
248
|
} else {
|
|
211
|
-
this.
|
|
249
|
+
this.contentEl.append(
|
|
212
250
|
h('div', { class: 'sm-empty-form' }, [`未注册的组件: ${loc.block.type}`]),
|
|
213
251
|
);
|
|
214
252
|
}
|
|
215
253
|
} else {
|
|
216
|
-
this.
|
|
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
|
-
|
|
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
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' &&
|
|
501
|
-
const
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
1739
|
+
stored = `${s}px`;
|
|
1740
|
+
onChange(stored);
|
|
1336
1741
|
});
|
|
1337
1742
|
|
|
1338
1743
|
num.addEventListener('input', () => {
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
-
|
|
1350
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|