suneditor 3.2.2 → 3.2.4

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 +26 -2
  7. package/src/core/event/effects/keydown.registry.js +83 -4
  8. package/src/core/event/eventOrchestrator.js +20 -3
  9. package/src/core/event/handlers/handler_ww_input.js +12 -5
  10. package/src/core/event/handlers/handler_ww_key.js +10 -7
  11. package/src/core/event/ports.js +16 -8
  12. package/src/core/event/reducers/keydown.reducer.js +19 -9
  13. package/src/core/event/rules/keydown.rule.backspace.js +15 -0
  14. package/src/core/event/rules/keydown.rule.delete.js +29 -0
  15. package/src/core/event/rules/keydown.rule.enter.js +15 -8
  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 +7 -5
  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 +4 -1
  30. package/types/core/event/effects/keydown.registry.d.ts +9 -2
  31. package/types/core/event/eventOrchestrator.d.ts +14 -3
  32. package/types/core/event/handlers/handler_ww_key.d.ts +1 -0
  33. package/types/core/event/ports.d.ts +8 -6
  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.4",
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
@@ -196,12 +210,15 @@ export const A = {
196
210
  */
197
211
  tabFormatIndent: (range, formatEl, shift) => ({ t: 'tab.format.indent', p: { range, formatEl, shift } }),
198
212
 
199
- // === enter ===
213
+ // === caret ===
200
214
  /**
215
+ * @description Scroll the editor view to the caret. Used after Enter, Backspace, and any edit that can move the caret out of view.
201
216
  * @param {Range} range Range object
202
217
  * @returns {Action}
203
218
  */
204
- enterScrollTo: (range) => ({ t: 'enter.scrollTo', p: { range } }),
219
+ caretScrollTo: (range) => ({ t: 'caret.scrollTo', p: { range } }),
220
+
221
+ // === enter ===
205
222
  /**
206
223
  * @param {Element} formatEl
207
224
  * @returns {Action}
@@ -253,6 +270,13 @@ export const A = {
253
270
  * @returns {Action}
254
271
  */
255
272
  enterBrLineInsert: (range) => ({ t: 'enter.brline.insert', p: { range } }),
273
+ /**
274
+ * @description Soft line break (Shift+Enter) — insert a `<br>` at the caret, splitting the line.
275
+ * Used instead of the browser's native `insertLineBreak`, which is inconsistent across browsers.
276
+ * @param {Range} range Caret range captured at rule time.
277
+ * @returns {Action}
278
+ */
279
+ enterShiftBr: (range) => ({ t: 'enter.shift.br', p: { range } }),
256
280
  /**
257
281
  * @description Exit a normal brLine — drop its trailing empty rows and add a default line after it.
258
282
  * @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 */
@@ -376,13 +402,15 @@ export default {
376
402
  ports.selection.setRange(r.sc, r.so, r.ec, r.eo);
377
403
  },
378
404
 
379
- /** [enter] */
405
+ /** [caret] */
380
406
 
381
- /** @action enterScrollTo */
382
- 'enter.scrollTo': ({ ports }, { range }) => {
383
- ports.enterScrollTo(range);
407
+ /** @action caretScrollTo */
408
+ 'caret.scrollTo': ({ ports }, { range }) => {
409
+ ports.caretScrollTo(range);
384
410
  },
385
411
 
412
+ /** [enter] */
413
+
386
414
  /** @action enterLineAddDefault */
387
415
  'enter.line.addDefault': ({ ports }, { formatEl }) => {
388
416
  const newFormat = ports.format.addLineAfter(formatEl);
@@ -486,6 +514,46 @@ export default {
486
514
  ports.setOnShortcutKey(true);
487
515
  },
488
516
 
517
+ /** @action enterShiftBr — soft line break (Shift+Enter): insert a `<br>` at the caret, splitting the line. */
518
+ 'enter.shift.br': ({ ports, ctx }, { range }) => {
519
+ const wd = ctx.fc.get('_wd');
520
+
521
+ const domSel = ports.selection.get();
522
+ const live = domSel?.rangeCount > 0 && ctx.fc.get('wysiwyg').contains(domSel.focusNode);
523
+ const src = live ? domSel.getRangeAt(0) : range;
524
+
525
+ const r = wd.createRange();
526
+ r.setStart(src.startContainer, src.startOffset);
527
+ r.setEnd(src.endContainer, src.endOffset);
528
+ if (!src.collapsed) {
529
+ r.deleteContents();
530
+ r.collapse(true);
531
+ }
532
+
533
+ if (r.startContainer.nodeType === 1 && dom.check.isBreak(r.startContainer)) {
534
+ const brNode = r.startContainer;
535
+ r.setStart(
536
+ brNode.parentNode,
537
+ dom.utils.getArrayIndex(brNode.parentNode.childNodes, brNode) + r.startOffset,
538
+ );
539
+ r.collapse(true);
540
+ }
541
+
542
+ const br = dom.utils.createElement('BR');
543
+ r.insertNode(br);
544
+
545
+ const next = br.nextSibling;
546
+ if (next && next.nodeType === 3 && !dom.check.isZeroWidth(next.textContent)) {
547
+ ports.selection.setRange(next, 0, next, 0);
548
+ } else {
549
+ const zws = dom.utils.createTextNode(unicode.zeroWidthSpace);
550
+ br.parentNode.insertBefore(zws, br.nextSibling);
551
+ ports.selection.setRange(zws, 1, zws, 1);
552
+ }
553
+
554
+ ports.history.push(false);
555
+ },
556
+
489
557
  /** @action enterBrLineExit — consume only the caret's current (last) empty row and add a default line after the brLine. */
490
558
  'enter.brline.exit': ({ ports }, { brBlock }) => {
491
559
  const last = brBlock.lastChild;
@@ -676,6 +744,17 @@ function caretToLineEdge(ports, line, toEnd) {
676
744
  ports.selection.setRange(line, offset, line, offset);
677
745
  }
678
746
 
747
+ /**
748
+ * @description Place a collapsed caret at the END of `node`, whether it is a Text node (end of its text)
749
+ * or an element (after its last child). A fixed offset would land mid-node for a multi-child inline wrapper.
750
+ * @param {import('../ports').EventReducerPorts} ports
751
+ * @param {Node} node
752
+ */
753
+ function caretToNodeEnd(ports, node) {
754
+ const offset = node.nodeType === 3 ? node.textContent.length : node.childNodes.length;
755
+ ports.selection.setRange(node, offset, node, offset);
756
+ }
757
+
679
758
  /**
680
759
  * @param {HTMLElement} formatEl - Format element
681
760
  * @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
  /**
@@ -205,11 +214,15 @@ class EventOrchestrator extends KernelInjector {
205
214
 
206
215
  /**
207
216
  * @internal
208
- * @description Normalize the Enter range before the reducer reads it: reset to a text node, and when the
209
- * caret sits inside a zero-width text node adjacent to a `<br>`, move it onto the `<br>`.
217
+ * @description Normalize the edit range before the reducer reads it, for every key we custom-handle
218
+ * (Enter, Backspace, Delete). Resets an element-level container (a `line` such as `P`) to a text node so
219
+ * the rules' text-offset arithmetic (`isEdgePoint`, `endOffset === textContent.length`, ...) is valid; and
220
+ * when the caret sits inside a zero-width text node adjacent to a `<br>`, moves it onto the `<br>`.
221
+ * Without this, Backspace/Delete misfire whenever the browser reports the container as the line itself
222
+ * (child-index offset) instead of a `<br>`/ZWS text node.
210
223
  * @returns {?(HTMLElement|Text)} The updated selection node, or `null` when the caret is not on a line (no normalization ran).
211
224
  */
212
- _normalizeEnterRange() {
225
+ _normalizeEditRange() {
213
226
  if (!this.$.format.isLine(this.$.selection.getRange()?.startContainer)) return null;
214
227
 
215
228
  this.$.selection.resetRangeToTextNode();
@@ -602,6 +615,9 @@ class EventOrchestrator extends KernelInjector {
602
615
  this.#eventManager.addEvent(parent, 'scroll', OnScrollAbs, false);
603
616
  }
604
617
 
618
+ /** focus temp (mobile) — used by the legacy `keydown` Enter path to force-end a virtual-keyboard IME session */
619
+ this.#eventManager.addEvent(this.__focusTemp, 'focus', (e) => e.preventDefault(), false);
620
+
605
621
  /** document event */
606
622
  if (this.__eventDoc !== fc.get('_wd')) {
607
623
  this.__eventDoc = fc.get('_wd');
@@ -678,6 +694,7 @@ class EventOrchestrator extends KernelInjector {
678
694
  this.__inputPlugin = null;
679
695
  this.__inputBlurEvent = null;
680
696
  this.__inputKeyEvent = null;
697
+ this.__focusTemp = null;
681
698
  this.__eventDoc = null;
682
699
  this.__secopy = null;
683
700
  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._normalizeEditRange();
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;
@@ -48,13 +50,14 @@ export async function OnKeyDown_wysiwyg(fc, e) {
48
50
  }
49
51
 
50
52
  /**
51
- * 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
53
- * on keydown re-traps the iOS/mobile IME marked-text — `dispatchEnter` runs this same normalization
54
- * later, after the IME has committed.
53
+ * default key action — normalize the edit range before the reducer reads it, for every custom-handled key.
54
+ * - Enter: skipped when handled from `beforeinput` (useEnterFromBeforeInput),
55
+ * - because mutating the DOM here on keydown re-traps the iOS/mobile IME marked-text.
56
+ * - Backspace/Delete: always normalize on keydown (they have no `beforeinput` route).
55
57
  */
56
- if (!ENTER_FROM_BEFOREINPUT && keyCodeMap.isEnter(keyCode)) {
57
- const normalized = this._normalizeEnterRange();
58
+ const enterOnKeydown = keyCodeMap.isEnter(keyCode) && !useEnterFromBeforeInput(this.$.store, e);
59
+ if (enterOnKeydown || keyCodeMap.isRemoveKey(keyCode)) {
60
+ const normalized = this._normalizeEditRange();
58
61
  if (normalized) selectionNode = normalized;
59
62
  }
60
63
 
@@ -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 {
@@ -169,15 +173,13 @@ export function makePorts(inst, { _styleNodes }) {
169
173
  formatAttrsTempCache: (attrs) => (inst._formatAttrsTemp = attrs),
170
174
  setOnShortcutKey: (v) => (inst._onShortcutKey = v),
171
175
 
172
- // === enter event specific ===
173
176
  /**
174
- * @description Scrolls the editor view to the caret position after pressing `Enter`.
175
- * @param {Range} range Pre-Enter snapshot range (fallback only).
177
+ * @description Scrolls the editor view to the caret position after an edit (Enter, Backspace, ...).
178
+ * @param {Range} range Pre-edit snapshot range (fallback only).
176
179
  */
177
- enterScrollTo(range) {
180
+ caretScrollTo(range) {
178
181
  ui._iframeAutoHeight(frameContext);
179
182
 
180
- // Scroll to the *live* post-Enter caret, not the pre-Enter `range` snapshot.
181
183
  selection.scrollTo(selection.getRange() || range, {
182
184
  behavior: 'auto',
183
185
  block: 'nearest',
@@ -186,12 +188,18 @@ export function makePorts(inst, { _styleNodes }) {
186
188
  },
187
189
  /**
188
190
  * @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.
191
+ * On the `beforeinput` Enter path the IME has already committed, so a plain `preventDefault` suffices.
192
+ * On the legacy `keydown` path (environment doesn't deliver `beforeinput` — see `useEnterFromBeforeInput`)
193
+ * a mobile virtual keyboard can leave an open IME session, so we force it to end with a focus-shuffle
194
+ * (temp-focus → refocus wysiwyg); otherwise the just-committed marked-text is re-trapped.
191
195
  * @param {Event} e The keyboard/input event
192
196
  */
193
197
  enterPrevent(e) {
194
198
  e.preventDefault();
199
+ if (!isMobile || useEnterFromBeforeInput(store)) return;
200
+
201
+ inst.__focusTemp.focus({ preventScroll: true });
202
+ frameContext.get('wysiwyg').focus({ preventScroll: true });
195
203
  },
196
204
  };
197
205
  }
@@ -221,5 +229,5 @@ export function makePorts(inst, { _styleNodes }) {
221
229
  * @property {(v: boolean) => void} setOnShortcutKey
222
230
  *
223
231
  * @property {(e: Event) => void} enterPrevent
224
- * @property {(range: Range) => void} enterScrollTo
232
+ * @property {(range: Range) => void} caretScrollTo
225
233
  */
@@ -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 (
@@ -320,5 +334,6 @@ export function reduceBackspaceDown(actions, ports, ctx) {
320
334
  return false;
321
335
  }
322
336
 
337
+ actions.push(A.caretScrollTo(range));
323
338
  return true;
324
339
  }
@@ -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.caretScrollTo(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');
@@ -100,7 +107,7 @@ export function reduceEnterDown(actions, ports, ctx) {
100
107
  if (formatEndEdge && (/^H[1-6]$/i.test(formatEl.nodeName) || /^HR$/i.test(formatEl.nodeName))) {
101
108
  ports.enterPrevent(e);
102
109
  actions.push(A.enterLineAddDefault(formatEl));
103
- actions.push(A.enterScrollTo(range));
110
+ actions.push(A.caretScrollTo(range));
104
111
  return true;
105
112
  } else if (rangeEl && formatEl && !dom.check.isTableCell(rangeEl) && !/^FIGCAPTION$/i.test(rangeEl.nodeName)) {
106
113
  // add default List line
@@ -111,7 +118,7 @@ export function reduceEnterDown(actions, ports, ctx) {
111
118
  ) {
112
119
  ports.enterPrevent(e);
113
120
  actions.push(A.enterListAddItem(formatEl, selectionNode));
114
- actions.push(A.enterScrollTo(range));
121
+ actions.push(A.caretScrollTo(range));
115
122
  return true;
116
123
  }
117
124
 
@@ -139,7 +146,7 @@ export function reduceEnterDown(actions, ports, ctx) {
139
146
  } else {
140
147
  actions.push(A.enterBrLineInsert(range));
141
148
  }
142
- actions.push(A.enterScrollTo(range));
149
+ actions.push(A.caretScrollTo(range));
143
150
  return true;
144
151
  }
145
152
 
@@ -150,7 +157,7 @@ export function reduceEnterDown(actions, ports, ctx) {
150
157
  } else {
151
158
  actions.push(A.enterFormatInsertBrNode(wSelection));
152
159
  }
153
- actions.push(A.enterScrollTo(range));
160
+ actions.push(A.caretScrollTo(range));
154
161
  return true;
155
162
  }
156
163
 
@@ -160,7 +167,7 @@ export function reduceEnterDown(actions, ports, ctx) {
160
167
  actions.push(
161
168
  A.enterFormatBreakAtEdge(formatEl, selectionNode, formatStartEdge, formatEndEdge, bidiSwapped),
162
169
  );
163
- actions.push(A.enterScrollTo(range));
170
+ actions.push(A.caretScrollTo(range));
164
171
  return true;
165
172
  }
166
173
 
@@ -187,13 +194,13 @@ export function reduceEnterDown(actions, ports, ctx) {
187
194
  actions.push(A.enterFormatBreakAtCursor(formatEl, range));
188
195
  }
189
196
 
190
- actions.push(A.enterScrollTo(range));
197
+ actions.push(A.caretScrollTo(range));
191
198
  return true;
192
199
  }
193
200
  }
194
201
 
195
202
  if (selectRange) {
196
- actions.push(A.enterScrollTo(range));
203
+ actions.push(A.caretScrollTo(range));
197
204
  return true;
198
205
  }
199
206
 
@@ -204,7 +211,7 @@ export function reduceEnterDown(actions, ports, ctx) {
204
211
  ) {
205
212
  ports.enterPrevent(e);
206
213
  actions.push(A.enterFigcaptionExitInList(formatEl));
207
- actions.push(A.enterScrollTo(range));
214
+ actions.push(A.caretScrollTo(range));
208
215
  }
209
216
 
210
217
  return true;
@@ -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