suneditor 3.2.2 → 3.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/suneditor.min.css +1 -1
  2. package/dist/suneditor.min.js +1 -1
  3. package/package.json +1 -1
  4. package/src/assets/suneditor.css +6 -1
  5. package/src/core/editor.js +2 -0
  6. package/src/core/event/actions/index.js +21 -0
  7. package/src/core/event/effects/keydown.registry.js +77 -0
  8. package/src/core/event/eventOrchestrator.js +13 -0
  9. package/src/core/event/handlers/handler_ww_input.js +12 -5
  10. package/src/core/event/handlers/handler_ww_key.js +5 -3
  11. package/src/core/event/ports.js +12 -2
  12. package/src/core/event/reducers/keydown.reducer.js +19 -9
  13. package/src/core/event/rules/keydown.rule.backspace.js +14 -0
  14. package/src/core/event/rules/keydown.rule.delete.js +29 -0
  15. package/src/core/event/rules/keydown.rule.enter.js +7 -0
  16. package/src/core/kernel/store.js +2 -0
  17. package/src/core/logic/dom/html.js +13 -1
  18. package/src/core/logic/panel/blockHandle.js +50 -14
  19. package/src/core/logic/shell/ui.js +3 -2
  20. package/src/core/schema/options.js +4 -2
  21. package/src/core/section/constructor.js +15 -0
  22. package/src/helper/converter.js +3 -2
  23. package/src/helper/env.js +51 -0
  24. package/src/helper/markdown.js +3 -1
  25. package/src/modules/contract/Browser.js +2 -2
  26. package/src/modules/ui/CommandMenu.js +1 -1
  27. package/src/plugins/dropdown/table/index.js +3 -2
  28. package/src/plugins/field/slashCommand.js +17 -1
  29. package/types/core/event/actions/index.d.ts +3 -0
  30. package/types/core/event/effects/keydown.registry.d.ts +6 -0
  31. package/types/core/event/eventOrchestrator.d.ts +7 -0
  32. package/types/core/event/handlers/handler_ww_key.d.ts +1 -0
  33. package/types/core/event/ports.d.ts +4 -2
  34. package/types/core/event/reducers/keydown.reducer.d.ts +15 -5
  35. package/types/core/kernel/store.d.ts +5 -0
  36. package/types/core/schema/options.d.ts +7 -4
  37. package/types/helper/env.d.ts +10 -0
  38. package/types/helper/index.d.ts +1 -0
  39. package/types/plugins/field/slashCommand.d.ts +34 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suneditor",
3
- "version": "3.2.2",
3
+ "version": "3.2.3",
4
4
  "description": "Vanilla JavaScript based WYSIWYG web editor",
5
5
  "author": "Yi JiHong",
6
6
  "license": "MIT",
@@ -3329,7 +3329,7 @@
3329
3329
  margin: 0;
3330
3330
  min-height: 28px;
3331
3331
  }
3332
- .sun-editor .se-select-menu .se-select-item .se-btn-list span {
3332
+ .sun-editor .se-select-menu .se-select-item .se-btn-list > span {
3333
3333
  padding: 0;
3334
3334
  margin: 0;
3335
3335
  }
@@ -4378,6 +4378,11 @@
4378
4378
  direction: rtl;
4379
4379
  }
4380
4380
 
4381
+ /* toolbar */
4382
+ .sun-editor.se-rtl .se-btn-tray {
4383
+ direction: rtl;
4384
+ }
4385
+
4381
4386
  /* statusbar */
4382
4387
  .sun-editor.se-rtl .se-status-bar {
4383
4388
  direction: rtl;
@@ -139,6 +139,8 @@ class Editor {
139
139
  this.$.store.set('initViewportHeight', env._w.visualViewport.height);
140
140
  this.#kernel._eventOrchestrator.__setViewportSize();
141
141
 
142
+ this.$.store.set('_canUseBeforeInput', env.canUseBeforeInput());
143
+
142
144
  this.$.contextProvider.init();
143
145
 
144
146
  // initialize core and add event listeners
@@ -149,6 +149,13 @@ export const A = {
149
149
  p: { rowEndBr, rowStartBr },
150
150
  }),
151
151
 
152
+ /**
153
+ * @description Soft line break merge (Backspace): the caret sits in the zero-width anchor right after a soft-break `<br>` (a Shift+Enter).
154
+ * @param {Node} zws - the zero-width Text node holding the caret (its `previousSibling` is the `<br>`)
155
+ * @returns {Action}
156
+ */
157
+ backspaceSoftBreakMerge: (zws) => ({ t: 'backspace.softBreak.merge', p: { zws } }),
158
+
152
159
  // === delete ===
153
160
  /**
154
161
  * @param {Element} formatEl
@@ -187,6 +194,13 @@ export const A = {
187
194
  */
188
195
  deleteBrLineRowMerge: (rowEndBr) => ({ t: 'delete.brline.rowMerge', p: { rowEndBr } }),
189
196
 
197
+ /**
198
+ * @description Soft line break merge (Delete): the caret sits just before a soft-break `<br>`.
199
+ * @param {Node} br - the soft-break `<br>` to remove
200
+ * @returns {Action}
201
+ */
202
+ deleteSoftBreakMerge: (br) => ({ t: 'delete.softBreak.merge', p: { br } }),
203
+
190
204
  // === tab ===
191
205
  /**
192
206
  * @param {Range} range
@@ -253,6 +267,13 @@ export const A = {
253
267
  * @returns {Action}
254
268
  */
255
269
  enterBrLineInsert: (range) => ({ t: 'enter.brline.insert', p: { range } }),
270
+ /**
271
+ * @description Soft line break (Shift+Enter) — insert a `<br>` at the caret, splitting the line.
272
+ * Used instead of the browser's native `insertLineBreak`, which is inconsistent across browsers.
273
+ * @param {Range} range Caret range captured at rule time.
274
+ * @returns {Action}
275
+ */
276
+ enterShiftBr: (range) => ({ t: 'enter.shift.br', p: { range } }),
256
277
  /**
257
278
  * @description Exit a normal brLine — drop its trailing empty rows and add a default line after it.
258
279
  * @param {Element} brBlock
@@ -155,6 +155,19 @@ export default {
155
155
  }
156
156
  },
157
157
 
158
+ /** @action backspaceSoftBreakMerge */
159
+ 'backspace.softBreak.merge': ({ ports }, { zws }) => {
160
+ const br = zws.previousSibling;
161
+ const parent = zws.parentNode;
162
+ const before = br?.previousSibling;
163
+
164
+ dom.utils.removeItem(zws);
165
+ if (br) dom.utils.removeItem(br);
166
+
167
+ if (before) caretToNodeEnd(ports, before);
168
+ else if (parent) ports.selection.setRange(parent, 0, parent, 0);
169
+ },
170
+
158
171
  /** [delete] */
159
172
 
160
173
  /** @action deleteComponentSelect */
@@ -252,6 +265,19 @@ export default {
252
265
  }
253
266
  },
254
267
 
268
+ /** @action deleteSoftBreakMerge */
269
+ 'delete.softBreak.merge': ({ ports }, { br }) => {
270
+ const before = br.previousSibling;
271
+ const parent = br.parentNode;
272
+ const zws = br.nextSibling;
273
+
274
+ if (zws && zws.nodeType === 3 && dom.check.isZeroWidth(zws.textContent)) dom.utils.removeItem(zws);
275
+ dom.utils.removeItem(br);
276
+
277
+ if (before) caretToNodeEnd(ports, before);
278
+ else if (parent) ports.selection.setRange(parent, 0, parent, 0);
279
+ },
280
+
255
281
  /** [tab] */
256
282
 
257
283
  /** @action tabFormatIndent */
@@ -486,6 +512,46 @@ export default {
486
512
  ports.setOnShortcutKey(true);
487
513
  },
488
514
 
515
+ /** @action enterShiftBr — soft line break (Shift+Enter): insert a `<br>` at the caret, splitting the line. */
516
+ 'enter.shift.br': ({ ports, ctx }, { range }) => {
517
+ const wd = ctx.fc.get('_wd');
518
+
519
+ const domSel = ports.selection.get();
520
+ const live = domSel?.rangeCount > 0 && ctx.fc.get('wysiwyg').contains(domSel.focusNode);
521
+ const src = live ? domSel.getRangeAt(0) : range;
522
+
523
+ const r = wd.createRange();
524
+ r.setStart(src.startContainer, src.startOffset);
525
+ r.setEnd(src.endContainer, src.endOffset);
526
+ if (!src.collapsed) {
527
+ r.deleteContents();
528
+ r.collapse(true);
529
+ }
530
+
531
+ if (r.startContainer.nodeType === 1 && dom.check.isBreak(r.startContainer)) {
532
+ const brNode = r.startContainer;
533
+ r.setStart(
534
+ brNode.parentNode,
535
+ dom.utils.getArrayIndex(brNode.parentNode.childNodes, brNode) + r.startOffset,
536
+ );
537
+ r.collapse(true);
538
+ }
539
+
540
+ const br = dom.utils.createElement('BR');
541
+ r.insertNode(br);
542
+
543
+ const next = br.nextSibling;
544
+ if (next && next.nodeType === 3 && !dom.check.isZeroWidth(next.textContent)) {
545
+ ports.selection.setRange(next, 0, next, 0);
546
+ } else {
547
+ const zws = dom.utils.createTextNode(unicode.zeroWidthSpace);
548
+ br.parentNode.insertBefore(zws, br.nextSibling);
549
+ ports.selection.setRange(zws, 1, zws, 1);
550
+ }
551
+
552
+ ports.history.push(false);
553
+ },
554
+
489
555
  /** @action enterBrLineExit — consume only the caret's current (last) empty row and add a default line after the brLine. */
490
556
  'enter.brline.exit': ({ ports }, { brBlock }) => {
491
557
  const last = brBlock.lastChild;
@@ -676,6 +742,17 @@ function caretToLineEdge(ports, line, toEnd) {
676
742
  ports.selection.setRange(line, offset, line, offset);
677
743
  }
678
744
 
745
+ /**
746
+ * @description Place a collapsed caret at the END of `node`, whether it is a Text node (end of its text)
747
+ * or an element (after its last child). A fixed offset would land mid-node for a multi-child inline wrapper.
748
+ * @param {import('../ports').EventReducerPorts} ports
749
+ * @param {Node} node
750
+ */
751
+ function caretToNodeEnd(ports, node) {
752
+ const offset = node.nodeType === 3 ? node.textContent.length : node.childNodes.length;
753
+ ports.selection.setRange(node, offset, node, offset);
754
+ }
755
+
679
756
  /**
680
757
  * @param {HTMLElement} formatEl - Format element
681
758
  * @returns {Node}
@@ -66,6 +66,12 @@ class EventOrchestrator extends KernelInjector {
66
66
  */
67
67
  this.isComposing = false;
68
68
 
69
+ /**
70
+ * @description Real Shift-key state of the most recent Enter keydown.
71
+ * @type {boolean}
72
+ */
73
+ this._enterKeyShift = false;
74
+
69
75
  /**
70
76
  * @description An array of parent containers that can be scrolled (in descending order)
71
77
  * @type {Array<Element>}
@@ -112,6 +118,9 @@ class EventOrchestrator extends KernelInjector {
112
118
  this.__eventDoc = null;
113
119
  /** @type {string} */
114
120
  this.__secopy = null;
121
+
122
+ /** @type {HTMLInputElement} */
123
+ this.__focusTemp = this.#contextProvider.carrierWrapper.querySelector('.__se__focus__temp__');
115
124
  }
116
125
 
117
126
  /**
@@ -602,6 +611,9 @@ class EventOrchestrator extends KernelInjector {
602
611
  this.#eventManager.addEvent(parent, 'scroll', OnScrollAbs, false);
603
612
  }
604
613
 
614
+ /** focus temp (mobile) — used by the legacy `keydown` Enter path to force-end a virtual-keyboard IME session */
615
+ this.#eventManager.addEvent(this.__focusTemp, 'focus', (e) => e.preventDefault(), false);
616
+
605
617
  /** document event */
606
618
  if (this.__eventDoc !== fc.get('_wd')) {
607
619
  this.__eventDoc = fc.get('_wd');
@@ -678,6 +690,7 @@ class EventOrchestrator extends KernelInjector {
678
690
  this.__inputPlugin = null;
679
691
  this.__inputBlurEvent = null;
680
692
  this.__inputKeyEvent = null;
693
+ this.__focusTemp = null;
681
694
  this.__eventDoc = null;
682
695
  this.__secopy = null;
683
696
  this._lineBreakComp = null;
@@ -2,7 +2,7 @@ import { dom, keyCodeMap } from '../../../helper';
2
2
  import { actionExecutor } from '../executor';
3
3
  import { makePorts } from '../ports';
4
4
  import { reduceEnterDown } from '../rules/keydown.rule.enter';
5
- import { ENTER_FROM_BEFOREINPUT } from '../reducers/keydown.reducer';
5
+ import { useEnterFromBeforeInput } from '../reducers/keydown.reducer';
6
6
 
7
7
  // The Enter rule/effects never touch the retain-style node cache (a backspace concern) — a local
8
8
  // placeholder is enough to satisfy `makePorts`.
@@ -27,7 +27,10 @@ export async function OnBeforeInput_wysiwyg(fc, e) {
27
27
  // Enter is dispatched here — not on keydown — so the IME has finished committing before the DOM
28
28
  // mutates (iOS/mobile marked-text stability). `insertParagraph` = Enter, `insertLineBreak` = Shift+Enter.
29
29
  // ctrl/alt+Enter are shortcuts and never produce these inputTypes, so they stay on the keydown path.
30
- if (ENTER_FROM_BEFOREINPUT && (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak')) {
30
+ if (
31
+ useEnterFromBeforeInput(this.$.store) &&
32
+ (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak')
33
+ ) {
31
34
  await dispatchEnter.call(this, fc, e);
32
35
  return;
33
36
  }
@@ -74,12 +77,16 @@ async function dispatchEnter(fc, e) {
74
77
  selectionNode = this.$.selection.getNode();
75
78
  }
76
79
 
77
- const normalized = this._normalizeEnterRange();
78
- if (normalized) selectionNode = normalized;
80
+ const shift = this._enterKeyShift || e.inputType === 'insertLineBreak';
81
+ this._enterKeyShift = false;
82
+
83
+ if (!shift) {
84
+ const normalized = this._normalizeEnterRange();
85
+ if (normalized) selectionNode = normalized;
86
+ }
79
87
 
80
88
  const range = this.$.selection.getRange();
81
89
  const formatEl = /** @type {HTMLElement} */ (this.$.format.getLine(selectionNode, null) || selectionNode);
82
- const shift = e.inputType === 'insertLineBreak';
83
90
 
84
91
  /** @type {import('../reducers/keydown.reducer').KeydownReducerCtx} */
85
92
  const ctx = {
@@ -1,7 +1,7 @@
1
1
  import { dom, env, unicode, keyCodeMap } from '../../../helper';
2
2
  import { actionExecutor } from '../executor';
3
3
  import { makePorts } from '../ports';
4
- import { reduceKeydown, ENTER_FROM_BEFOREINPUT } from '../reducers/keydown.reducer';
4
+ import { reduceKeydown, useEnterFromBeforeInput } from '../reducers/keydown.reducer';
5
5
 
6
6
  const { _w } = env;
7
7
  const FRONT_ZEROWIDTH = new RegExp(unicode.zeroWidthSpace + '+', '');
@@ -34,6 +34,8 @@ export async function OnKeyDown_wysiwyg(fc, e) {
34
34
  const ctrl = (keyState.ctrl = keyCodeMap.isCtrl(e));
35
35
  const alt = (keyState.alt = keyCodeMap.isAlt(e));
36
36
 
37
+ if (keyCodeMap.isEnter(keyCode)) this._enterKeyShift = shift;
38
+
37
39
  if (!ctrl && fc.get('isReadOnly') && !keyCodeMap.isDirectionKey(keyCode)) {
38
40
  e.preventDefault();
39
41
  return false;
@@ -49,11 +51,11 @@ export async function OnKeyDown_wysiwyg(fc, e) {
49
51
 
50
52
  /**
51
53
  * default key action — normalize the Enter range before the reducer reads it.
52
- * Skipped when Enter is handled from `beforeinput` (ENTER_FROM_BEFOREINPUT): mutating the DOM here
54
+ * Skipped when Enter is handled from `beforeinput` (useEnterFromBeforeInput): mutating the DOM here
53
55
  * on keydown re-traps the iOS/mobile IME marked-text — `dispatchEnter` runs this same normalization
54
56
  * later, after the IME has committed.
55
57
  */
56
- if (!ENTER_FROM_BEFOREINPUT && keyCodeMap.isEnter(keyCode)) {
58
+ if (!useEnterFromBeforeInput(this.$.store, e) && keyCodeMap.isEnter(keyCode)) {
57
59
  const normalized = this._normalizeEnterRange();
58
60
  if (normalized) selectionNode = normalized;
59
61
  }
@@ -1,3 +1,6 @@
1
+ import { isMobile } from '../../helper/env';
2
+ import { useEnterFromBeforeInput } from './reducers/keydown.reducer';
3
+
1
4
  /**
2
5
  * @typedef {import('./eventOrchestrator').default} EventManagerInstanceType
3
6
  */
@@ -94,6 +97,7 @@ export function makePorts(inst, { _styleNodes }) {
94
97
  history,
95
98
  char,
96
99
  menu,
100
+ store,
97
101
  } = inst.$;
98
102
 
99
103
  return {
@@ -186,12 +190,18 @@ export function makePorts(inst, { _styleNodes }) {
186
190
  },
187
191
  /**
188
192
  * @description Prevents the default behavior of the `Enter` key.
189
- * Enter now runs from `beforeinput` (post-IME-commit), so the former mobile focus-shuffle
190
- * (temp-focus → refocus, to force-end a virtual-keyboard IME session) is unnecessary.
193
+ * On the `beforeinput` Enter path the IME has already committed, so a plain `preventDefault` suffices.
194
+ * On the legacy `keydown` path (environment doesn't deliver `beforeinput` — see `useEnterFromBeforeInput`)
195
+ * a mobile virtual keyboard can leave an open IME session, so we force it to end with a focus-shuffle
196
+ * (temp-focus → refocus wysiwyg); otherwise the just-committed marked-text is re-trapped.
191
197
  * @param {Event} e The keyboard/input event
192
198
  */
193
199
  enterPrevent(e) {
194
200
  e.preventDefault();
201
+ if (!isMobile || useEnterFromBeforeInput(store)) return;
202
+
203
+ inst.__focusTemp.focus({ preventScroll: true });
204
+ frameContext.get('wysiwyg').focus({ preventScroll: true });
195
205
  },
196
206
  };
197
207
  }
@@ -10,15 +10,28 @@ import { A } from '../actions';
10
10
  const { isOSX_IOS } = env;
11
11
 
12
12
  /**
13
- * @description Enter is processed from the `beforeinput` event (post-IME-commit) instead of `keydown`,
14
- * to avoid trapping iOS/mobile IME marked-text when `keydown` mutates the DOM (see `handler_ww_input.js`).
15
- * Flip to `false` to instantly restore the legacy synchronous `keydown` Enter path — no other file needs
16
- * touching for rollback (the `keydown` Enter gate, the `handler_ww_key` normalization guard, and the
17
- * `beforeinput` dispatch all key off this single flag).
13
+ * @description Master switch for processing Enter from the `beforeinput` event (post-IME-commit) instead
14
+ * of `keydown`, to avoid trapping iOS/mobile IME marked-text when `keydown` mutates the DOM (see
15
+ * `handler_ww_input.js`).
16
+ *
17
+ * This is only the compile-time master; the effective per-environment decision is
18
+ * {@link useEnterFromBeforeInput}, which additionally requires that this environment actually delivers
19
+ * `beforeinput` at runtime (some corporate security SW / DLP / VDI drop it).
18
20
  * @type {boolean}
19
21
  */
20
22
  export const ENTER_FROM_BEFOREINPUT = true;
21
23
 
24
+ /**
25
+ * @description Routing decision for Enter. Auto-repeat (held key) fires `keydown` but not `beforeinput`,
26
+ * so a repeat must stay on `keydown` or only the first line break would land.
27
+ * @param {SunEditor.Store} store - Editor store object
28
+ * @param {KeyboardEvent|InputEvent} [e] - Source event; a `repeat` flag forces the `keydown` path.
29
+ * @returns {boolean} `true` to route Enter through `beforeinput`, `false` to keep it on `keydown`.
30
+ */
31
+ export function useEnterFromBeforeInput(store, e) {
32
+ return ENTER_FROM_BEFOREINPUT && store.get('_canUseBeforeInput') && !(/** @type {KeyboardEvent} */ (e)?.repeat);
33
+ }
34
+
22
35
  /**
23
36
  * @typedef {import('../ports').EventReducerPorts} EventPorts
24
37
  */
@@ -72,10 +85,7 @@ export async function reduceKeydown(ports, ctx) {
72
85
  break;
73
86
  }
74
87
  case 'Enter' /** enter key */: {
75
- // Enter is handled in `beforeinput` (IME stability) — see ENTER_FROM_BEFOREINPUT.
76
- // Gate only (not an early-return) so the post-switch `documentTypeRefreshHeader`
77
- // still runs for a selectRange + Enter on keydown.
78
- if (!ENTER_FROM_BEFOREINPUT) {
88
+ if (!useEnterFromBeforeInput(ctx.store, ctx.e)) {
79
89
  if (reduceEnterDown(actions, ports, ctx) === false) {
80
90
  return actions;
81
91
  }
@@ -304,6 +304,20 @@ export function reduceBackspaceDown(actions, ports, ctx) {
304
304
  }
305
305
  }
306
306
 
307
+ // soft line break (Shift+Enter)
308
+ if (
309
+ !selectRange &&
310
+ range.startContainer.nodeType === 3 &&
311
+ dom.check.isZeroWidth(range.startContainer) &&
312
+ range.startOffset <= 1 &&
313
+ dom.check.isBreak(range.startContainer.previousSibling)
314
+ ) {
315
+ actions.push(A.preventStop());
316
+ actions.push(A.backspaceSoftBreakMerge(range.startContainer));
317
+ actions.push(A.historyPush(true));
318
+ return false;
319
+ }
320
+
307
321
  // empty line: merge into the previous line
308
322
  const emptyLinePrev = formatEl?.previousElementSibling;
309
323
  if (
@@ -169,6 +169,35 @@ export function reduceDeleteDown(actions, ports, ctx) {
169
169
  }
170
170
  }
171
171
 
172
+ // soft line break (Shift+Enter)
173
+ if (!selectRange) {
174
+ const dcon = range.startContainer;
175
+ let softBr = null;
176
+
177
+ if (
178
+ dcon.nodeType === 3 &&
179
+ range.startOffset >= dcon.textContent.length &&
180
+ dom.check.isBreak(dcon.nextSibling)
181
+ ) {
182
+ const br = dcon.nextSibling;
183
+ if (br.nextSibling?.nodeType === 3 && dom.check.isZeroWidth(br.nextSibling.textContent)) softBr = br;
184
+ } else if (
185
+ dcon.nodeType === 3 &&
186
+ dom.check.isZeroWidth(dcon) &&
187
+ range.startOffset === 0 &&
188
+ dom.check.isBreak(dcon.previousSibling)
189
+ ) {
190
+ softBr = dcon.previousSibling;
191
+ }
192
+
193
+ if (softBr) {
194
+ actions.push(A.preventStop());
195
+ actions.push(A.deleteSoftBreakMerge(softBr));
196
+ actions.push(A.historyPush(true));
197
+ return false;
198
+ }
199
+ }
200
+
172
201
  // empty line
173
202
  const emptyLineNext = formatEl?.nextElementSibling;
174
203
  if (
@@ -76,6 +76,13 @@ export function reduceEnterDown(actions, ports, ctx) {
76
76
  }
77
77
  }
78
78
 
79
+ if (shift) {
80
+ ports.enterPrevent(e);
81
+ actions.push(A.enterShiftBr(range));
82
+ actions.push(A.enterScrollTo(range));
83
+ return true;
84
+ }
85
+
79
86
  if (!shift) {
80
87
  let formatEndEdge = format.isEdgeLine(range.endContainer, range.endOffset, 'end');
81
88
  let formatStartEdge = format.isEdgeLine(range.startContainer, range.startOffset, 'front');
@@ -18,6 +18,7 @@ import { numbers } from '../../helper';
18
18
  * @property {boolean} _mousedown - Whether `mousedown` is pressed.
19
19
  * @property {boolean} _preventBlur - Suppress `blur` event handling.
20
20
  * @property {boolean} _preventFocus - Suppress `focus` event handling.
21
+ * @property {boolean} _canUseBeforeInput - Whether this environment actually delivers `beforeinput` at runtime (probed once at editor load; some corporate security SW / DLP / VDI drop it). Defaults `true`.
21
22
  */
22
23
 
23
24
  /**
@@ -100,6 +101,7 @@ class Store {
100
101
  _mousedown: false,
101
102
  _preventBlur: false,
102
103
  _preventFocus: false,
104
+ _canUseBeforeInput: true,
103
105
  };
104
106
  }
105
107
 
@@ -3,6 +3,7 @@ import { dom, converter, markdown, numbers, unicode, clipboard, env } from '../.
3
3
  const { _d } = env;
4
4
  const REQUIRED_DATA_ATTRS = 'data-se-[^\\s]+';
5
5
  const V2_MIG_DATA_ATTRS = '|data-index|data-file-size|data-file-name|data-exp|data-font-size';
6
+ const ZWS_RUN_REGEXP = new RegExp(unicode.zeroWidthSpace + '+', 'g');
6
7
 
7
8
  /**
8
9
  * @description All HTML related classes involved in the editing area
@@ -365,6 +366,14 @@ class HTML {
365
366
  cleanData = this.#styleNodeConvertor(cleanData);
366
367
  }
367
368
 
369
+ if (!_freeCodeViewMode && cleanData.includes(unicode.zeroWidthSpace)) {
370
+ cleanData = cleanData.replace(ZWS_RUN_REGEXP, (m, offset, str) => {
371
+ const boundedStart = offset === 0 || str[offset - 1] === '>';
372
+ const boundedEnd = offset + m.length === str.length || str[offset + m.length] === '<';
373
+ return boundedStart && boundedEnd ? m : '';
374
+ });
375
+ }
376
+
368
377
  return cleanData;
369
378
  }
370
379
 
@@ -2228,7 +2237,9 @@ class HTML {
2228
2237
  v.push(sv[0]);
2229
2238
  }
2230
2239
  } else if (!v || !_RE_STYLE_EQ.test(v.toString())) {
2231
- if (this.#cleanStyleTagKeyRegExp.test(tagName)) {
2240
+ if (_RE_SE_COMPONENT.test(m)) {
2241
+ v = this.#cleanStyle(m, v, '@component');
2242
+ } else if (this.#cleanStyleTagKeyRegExp.test(tagName)) {
2232
2243
  v = this.#cleanStyle(m, v, tagName);
2233
2244
  } else if (this.#$.format.isLine(tagName)) {
2234
2245
  v = this.#cleanStyle(m, v, '@line');
@@ -2356,6 +2367,7 @@ const _RE_TAG_NAME = /(?!<)[a-zA-Z0-9-]+/;
2356
2367
  const _RE_ON_HANDLER = /\s(?:on[a-z]+)\s*=\s*(?:(["'])[^"']*\1|\S+)/gi;
2357
2368
  const _RE_STYLE_EQ = /style=/i;
2358
2369
  const _RE_STYLE_ATTR = /style\s*=\s*(?:"|')[^"']*(?:"|')/;
2370
+ const _RE_SE_COMPONENT = /class\s*=\s*("|')(?:[^"']*\s)?se-component(?=[\s"'])/i;
2359
2371
  const _RE_LEADING_SPACE = /^\s/;
2360
2372
 
2361
2373
  // #cleanStyle
@@ -1,4 +1,4 @@
1
- import { dom, env } from '../../../helper';
1
+ import { dom, env, numbers } from '../../../helper';
2
2
  import { resolveBlock } from './blockResolver';
3
3
  import { ResolveButton } from '../../section/constructor';
4
4
  import CommandMenu from '../../../modules/ui/CommandMenu.js';
@@ -14,13 +14,13 @@ const { _w, _d } = env;
14
14
  */
15
15
  function _resolveLengthPx(value, contextStyle) {
16
16
  const trimmed = (value || '').trim();
17
- const num = parseFloat(trimmed);
17
+ const num = numbers.get(trimmed, -1);
18
18
  if (!num) return 0;
19
19
  if (/rem$/.test(trimmed)) {
20
- return num * (parseFloat(_w.getComputedStyle(_d.documentElement).fontSize) || 16);
20
+ return num * (numbers.get(_w.getComputedStyle(_d.documentElement).fontSize, -1) || 16);
21
21
  }
22
22
  if (/em$/.test(trimmed)) {
23
- return num * (parseFloat(contextStyle.fontSize) || 16);
23
+ return num * (numbers.get(contextStyle.fontSize, -1) || 16);
24
24
  }
25
25
  return num;
26
26
  }
@@ -455,9 +455,18 @@ class BlockHandle {
455
455
  const scrollX = _w.scrollX;
456
456
  const scrollY = _w.scrollY;
457
457
 
458
- // parent-viewport top to convert to parent coordinates
459
- const blockTopVP = isIframe ? blockRect.top + iframeRect.top : blockRect.top;
460
- const top = blockTopVP + scrollY;
458
+ // First appearance after being hidden — skip transition. Make the handle
459
+ // measurable (display) before reading its height so we can center it.
460
+ const wasHidden = this.#handle.style.display !== 'flex';
461
+ if (wasHidden) {
462
+ dom.utils.addClass(this.#handle, 'se-no-transition');
463
+ this.#handle.style.display = 'flex';
464
+ }
465
+
466
+ const handleHeight = this.#handle.offsetHeight || 0;
467
+ const firstLineCenter = this.#getFirstLineCenter(blockElement, blockRect);
468
+ const firstLineCenterVP = isIframe ? firstLineCenter + iframeRect.top : firstLineCenter;
469
+ const top = firstLineCenterVP + scrollY - handleHeight / 2;
461
470
 
462
471
  // Handle inline offset
463
472
  const isRtl = !!this.#$.options.get('_rtl');
@@ -469,7 +478,7 @@ class BlockHandle {
469
478
  const wysiwygFrame = this.#$.frameContext.get('wysiwyg');
470
479
  if (wysiwygFrame) {
471
480
  const cs = _w.getComputedStyle(wysiwygFrame);
472
- const padStart = parseFloat(isRtl ? cs.paddingRight : cs.paddingLeft) || 0;
481
+ const padStart = numbers.get(isRtl ? cs.paddingRight : cs.paddingLeft, -1) || 0;
473
482
  const baseline = _resolveLengthPx(cs.getPropertyValue('--se-edit-inner-padding'), cs);
474
483
  centeringExtra = Math.max(0, padStart - baseline);
475
484
  }
@@ -477,10 +486,6 @@ class BlockHandle {
477
486
 
478
487
  const totalOffset = indent + centeringExtra;
479
488
 
480
- // First appearance after being hidden — skip transition
481
- const wasHidden = this.#handle.style.display !== 'flex';
482
- if (wasHidden) dom.utils.addClass(this.#handle, 'se-no-transition');
483
-
484
489
  this.#handle.style.top = top + 'px';
485
490
  if (isRtl) {
486
491
  this.#handle.style.left = '';
@@ -497,6 +502,37 @@ class BlockHandle {
497
502
  }
498
503
  }
499
504
 
505
+ /**
506
+ * @description Viewport-Y center of the block's first line box (in the block's own document coordinates).
507
+ * @param {HTMLElement} blockElement
508
+ * @param {DOMRect} blockRect - `blockElement.getBoundingClientRect()`
509
+ * @returns {number} Viewport-Y coordinate of the first line's vertical center
510
+ */
511
+ #getFirstLineCenter(blockElement, blockRect) {
512
+ try {
513
+ const doc = blockElement.ownerDocument;
514
+ const walker = doc.createTreeWalker(blockElement, NodeFilter.SHOW_TEXT, null);
515
+
516
+ let textNode = /** @type {Text|null} */ (null);
517
+ while ((textNode = /** @type {Text} */ (walker.nextNode()))) {
518
+ if (textNode.textContent?.trim()) break;
519
+ }
520
+
521
+ if (textNode) {
522
+ const range = doc.createRange();
523
+ range.setStart(textNode, 0);
524
+ range.setEnd(textNode, Math.min(1, textNode.length));
525
+ const rects = range.getClientRects();
526
+ if (rects.length) return (rects[0].top + rects[0].bottom) / 2;
527
+ }
528
+ } catch {
529
+ // Fall through to the line-height estimate below
530
+ }
531
+
532
+ const lineHeight = numbers.get(_w.getComputedStyle(blockElement).lineHeight, -1) || blockRect.height;
533
+ return blockRect.top + Math.min(lineHeight, blockRect.height) / 2;
534
+ }
535
+
500
536
  /**
501
537
  * @description Calculate the handle's inline indent.
502
538
  * @param {HTMLElement} blockElement
@@ -512,13 +548,13 @@ class BlockHandle {
512
548
  const paddingKey = isRtl ? 'paddingRight' : 'paddingLeft';
513
549
  let indent = 0;
514
550
 
515
- indent += parseFloat(_w.getComputedStyle(blockElement)[marginKey]) || 0;
551
+ indent += numbers.get(_w.getComputedStyle(blockElement)[marginKey], -1) || 0;
516
552
 
517
553
  let el = blockElement.parentElement;
518
554
  while (el && el !== wysiwyg) {
519
555
  if (format.isBlock(el)) {
520
556
  const s = _w.getComputedStyle(el);
521
- indent += (parseFloat(s[paddingKey]) || 0) + (parseFloat(s[marginKey]) || 0);
557
+ indent += (numbers.get(s[paddingKey], -1) || 0) + (numbers.get(s[marginKey], -1) || 0);
522
558
  }
523
559
  el = el.parentElement;
524
560
  }
@@ -888,7 +888,8 @@ class UIManager {
888
888
  const prev = wysiwyg.querySelector('.se-placeholder-line');
889
889
 
890
890
  const line = this.#store.get('hasFocus') ? this.#$.format.getLine(this.#$.selection.selectionNode) : null;
891
- const target = dom.check.isEmptyLine(line) && !dom.check.isListCell(line) ? line : null;
891
+ const inTableCell = !!line && !!dom.query.getParentElement(line, dom.check.isTableCell);
892
+ const target = dom.check.isEmptyLine(line) && !dom.check.isListCell(line) && !inTableCell ? line : null;
892
893
 
893
894
  // Single-marker invariant: drop the previous marker unless it is still the target line.
894
895
  if (prev && prev !== target) {
@@ -1047,7 +1048,7 @@ class UIManager {
1047
1048
 
1048
1049
  function CreateAlertHTML({ lang, icons }) {
1049
1050
  const html =
1050
- '<div><button class="close" data-command="close" title="' +
1051
+ '<div><button type="button" class="close" data-command="close" title="' +
1051
1052
  lang.close +
1052
1053
  '">' +
1053
1054
  icons.cancel +