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