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