suneditor 3.1.0 → 3.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suneditor",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "Vanilla JavaScript based WYSIWYG web editor",
5
5
  "author": "Yi JiHong",
6
6
  "license": "MIT",
@@ -168,4 +168,4 @@
168
168
  "web editor",
169
169
  "browser editor"
170
170
  ]
171
- }
171
+ }
@@ -8,7 +8,8 @@
8
8
 
9
9
  /** --------------------------- layout - [size] ----------- */
10
10
  /** main, common */
11
- --se-border-radius: 2px;
11
+ --se-border-radius: 3px;
12
+ --se-border-radius-lg: 6px;
12
13
  --se-min-height: 65px;
13
14
  --se-scroll-padding: 2em;
14
15
 
@@ -963,6 +963,18 @@
963
963
  bottom: 0px;
964
964
  }
965
965
 
966
+ /* Bottom toolbar: tooltip appears above */
967
+ .sun-editor .se-toolbar.se-toolbar-bottom .se-tooltip .se-tooltip-inner {
968
+ top: auto;
969
+ bottom: 120%;
970
+ }
971
+
972
+ .sun-editor .se-toolbar.se-toolbar-bottom .se-tooltip .se-tooltip-inner .se-tooltip-text::after {
973
+ top: 100%;
974
+ bottom: auto;
975
+ border-color: var(--se-main-font-color) transparent transparent transparent;
976
+ }
977
+
966
978
  /* toolbar arrow up */
967
979
  .sun-editor .se-toolbar .se-arrow.se-arrow-up {
968
980
  border-bottom-color: var(--se-main-divider-color);
@@ -1156,7 +1168,7 @@
1156
1168
  height: auto;
1157
1169
  z-index: 5;
1158
1170
  border: 1px solid var(--se-main-divider-color);
1159
- border-radius: var(--se-border-radius);
1171
+ border-radius: var(--se-border-radius-lg);
1160
1172
  padding: 4px;
1161
1173
  background-color: var(--se-main-background-color);
1162
1174
  box-shadow: 0 3px 9px var(--se-shadow-layer-color);
@@ -1189,7 +1201,8 @@
1189
1201
 
1190
1202
  /* dropdown layer - basic list */
1191
1203
  .sun-editor .se-list-inner .se-list-basic li {
1192
- width: 100%;
1204
+ width: auto;
1205
+ max-width: 100%;
1193
1206
  }
1194
1207
  .sun-editor .se-list-inner input {
1195
1208
  border-radius: 0;
@@ -1563,6 +1576,19 @@
1563
1576
  stroke-width: 2px;
1564
1577
  }
1565
1578
 
1579
+ .sun-editor .se-list-layer .se-selector-color .se-color-pallet:first-child li:first-child button {
1580
+ border-top-left-radius: var(--se-border-radius);
1581
+ }
1582
+ .sun-editor .se-list-layer .se-selector-color .se-color-pallet:first-child li:last-child button {
1583
+ border-top-right-radius: var(--se-border-radius);
1584
+ }
1585
+ .sun-editor .se-list-layer .se-selector-color .se-color-pallet:last-of-type li:first-child button {
1586
+ border-bottom-left-radius: var(--se-border-radius);
1587
+ }
1588
+ .sun-editor .se-list-layer .se-selector-color .se-color-pallet:last-of-type li:last-child button {
1589
+ border-bottom-right-radius: var(--se-border-radius);
1590
+ }
1591
+
1566
1592
  /* --- hue slider -------------------------------------------------------------- */
1567
1593
  .sun-editor .se-hue-slider {
1568
1594
  padding: 14px;
@@ -1997,7 +2023,7 @@
1997
2023
  -webkit-background-clip: padding-box;
1998
2024
  background-clip: padding-box;
1999
2025
  border: 1px solid rgba(0, 0, 0, 0.2);
2000
- border-radius: var(--se-border-radius);
2026
+ border-radius: var(--se-border-radius-lg);
2001
2027
  outline: 0;
2002
2028
  box-shadow: 0 3px 9px var(--se-shadow-layer-color);
2003
2029
  }
@@ -2558,6 +2584,7 @@
2558
2584
  color: var(--se-controller-color);
2559
2585
  -webkit-background-clip: padding-box;
2560
2586
  background-clip: padding-box;
2587
+ border-radius: var(--se-border-radius);
2561
2588
  line-break: auto;
2562
2589
  }
2563
2590
 
@@ -2582,13 +2609,13 @@
2582
2609
 
2583
2610
  .sun-editor-editable[contenteditable='true'] figure figcaption {
2584
2611
  display: block;
2585
- z-index: 2;
2612
+ z-index: 11;
2586
2613
  }
2587
2614
 
2588
2615
  .sun-editor-editable[contenteditable='true']:not(.se-read-only) figure:not(.se-input-component)::after {
2589
2616
  position: absolute;
2590
2617
  content: '';
2591
- z-index: 1;
2618
+ z-index: 10;
2592
2619
  top: 0;
2593
2620
  left: 0;
2594
2621
  right: 0;
@@ -2960,10 +2987,10 @@
2960
2987
  overflow-x: visible;
2961
2988
  overflow: visible;
2962
2989
  background-color: var(--se-main-background-color);
2963
- border-radius: var(--se-border-radius);
2964
- padding: 2px 0;
2990
+ border-radius: var(--se-border-radius-lg);
2991
+ padding: 4px 0;
2965
2992
  margin: 0;
2966
- border: 1px solid var(--se-main-outline-color);
2993
+ border: 1px solid var(--se-main-divider-color);
2967
2994
  box-shadow: 0 3px 9px var(--se-shadow-layer-color);
2968
2995
  outline: 0 none;
2969
2996
  }
@@ -2990,8 +3017,10 @@
2990
3017
  min-height: 28px;
2991
3018
  font-size: var(--se-btn-font-size);
2992
3019
  padding: 0 6px;
2993
- margin: 2px 0;
3020
+ margin: 1px 4px;
2994
3021
  cursor: pointer;
3022
+ border-radius: var(--se-border-radius);
3023
+ transition: background-color 0.08s ease;
2995
3024
  }
2996
3025
 
2997
3026
  .sun-editor .se-select-menu .se-select-item button {
@@ -3011,21 +3040,12 @@
3011
3040
 
3012
3041
  .sun-editor .se-select-menu.se-select-menu-mouse-move .se-select-item:hover,
3013
3042
  .sun-editor .se-select-menu:not(.se-select-menu-mouse-move) .se-select-item.active {
3014
- border-color: var(--se-active-light3-color) !important;
3015
- outline: 1px solid var(--se-active-color) !important;
3016
- box-shadow: 0 0 0 0.3rem var(--se-active-light3-color);
3017
- transition: box-shadow 0.1s ease-in-out;
3043
+ background-color: var(--se-hover-light-color);
3018
3044
  }
3019
3045
 
3020
3046
  .sun-editor .se-select-menu.se-select-menu-mouse-move .se-select-item:active,
3021
3047
  .sun-editor .se-select-menu.se-select-menu-mouse-move .se-select-item.__se__active {
3022
- background-color: var(--se-active-light4-color);
3023
- border-color: var(--se-active-light4-color) !important;
3024
- outline: 1px solid var(--se-active-color) !important;
3025
- box-shadow: none;
3026
- transition:
3027
- background-color 0.1s ease-in-out,
3028
- box-shadow 0.1s ease-in-out;
3048
+ background-color: var(--se-hover-light2-color);
3029
3049
  }
3030
3050
 
3031
3051
  .sun-editor .se-modal-form-files .se-select-menu {
@@ -3086,7 +3106,7 @@
3086
3106
  -webkit-background-clip: padding-box;
3087
3107
  background-clip: padding-box;
3088
3108
  border: 1px solid rgba(0, 0, 0, 0.2);
3089
- border-radius: var(--se-border-radius);
3109
+ border-radius: var(--se-border-radius-lg);
3090
3110
  outline: 0;
3091
3111
  box-shadow: 0 3px 9px var(--se-shadow-layer-color);
3092
3112
  }
@@ -3943,6 +3963,12 @@
3943
3963
  .sun-editor.se-rtl .se-find-replace-row {
3944
3964
  justify-content: flex-start;
3945
3965
  }
3966
+ .sun-editor.se-rtl .se-find-replace-input {
3967
+ direction: rtl;
3968
+ }
3969
+ .sun-editor.se-rtl .se-input-form {
3970
+ direction: rtl;
3971
+ }
3946
3972
 
3947
3973
  /* statusbar */
3948
3974
  .sun-editor.se-rtl .se-status-bar {
@@ -245,6 +245,7 @@ class Editor {
245
245
  converter._setAutoHeightStyle(targetOptions.get('height'));
246
246
  frame.contentDocument.body.className = originOptions.get('_editableClass');
247
247
  frame.contentDocument.body.setAttribute('contenteditable', 'true');
248
+ if (originOptions.get('_rtl')) frame.contentDocument.body.dir = 'rtl';
248
249
  }
249
250
 
250
251
  /**
@@ -203,9 +203,10 @@ export const A = {
203
203
  * @param {Node} selectionNode
204
204
  * @param {boolean} formatStartEdge
205
205
  * @param {boolean} formatEndEdge
206
+ * @param {boolean} [bidiSwapped]
206
207
  * @returns {Action}
207
208
  */
208
- enterFormatBreakAtEdge: (formatEl, selectionNode, formatStartEdge, formatEndEdge) => ({ t: 'enter.format.breakAtEdge', p: { formatEl, selectionNode, formatStartEdge, formatEndEdge } }),
209
+ enterFormatBreakAtEdge: (formatEl, selectionNode, formatStartEdge, formatEndEdge, bidiSwapped) => ({ t: 'enter.format.breakAtEdge', p: { formatEl, selectionNode, formatStartEdge, formatEndEdge, bidiSwapped } }),
209
210
  /**
210
211
  * @param {Element} formatEl
211
212
  * @param {Range} range
@@ -411,7 +411,7 @@ export default {
411
411
  },
412
412
 
413
413
  /** @action enterFormatBreakAtEdge */
414
- 'enter.format.breakAtEdge': ({ ports, ctx }, { formatEl, selectionNode, formatStartEdge, formatEndEdge }) => {
414
+ 'enter.format.breakAtEdge': ({ ports, ctx }, { formatEl, selectionNode, formatStartEdge, formatEndEdge, bidiSwapped }) => {
415
415
  const focusBR = dom.utils.createElement('BR');
416
416
  const newFormat = dom.utils.createElement(formatEl.nodeName, null, focusBR);
417
417
 
@@ -432,6 +432,8 @@ export default {
432
432
  formatEl.parentNode.insertBefore(newFormat, formatStartEdge && !formatEndEdge ? formatEl : formatEl.nextElementSibling);
433
433
  if (formatEndEdge) {
434
434
  ports.selection.setRange(focusBR, 1, focusBR, 1);
435
+ } else if (bidiSwapped) {
436
+ ports.selection.setRange(formatEl, 1, formatEl, 1);
435
437
  } else {
436
438
  const firstEl = formatEl.firstChild || formatEl;
437
439
  ports.selection.setRange(firstEl, 0, firstEl, 0);
@@ -145,4 +145,33 @@ function setDefaultLine(ports, lineTagName) {
145
145
  return ports.setDefaultLine(lineTagName);
146
146
  }
147
147
 
148
- export { hardDelete, cleanRemovedTags, isUneditableNode, setDefaultLine };
148
+ /**
149
+ * @description Detects if a detected logical edge is incorrect due to bidi text direction mismatch in RTL mode.
150
+ * When LTR text (numbers, Latin) is inside an RTL line, the browser may place the caret at offset 0
151
+ * for the visual end or offset=length for the visual start. This function compares the caret's visual
152
+ * position against the content boundaries to detect such mismatches.
153
+ * @param {Range} range - The current collapsed range
154
+ * @param {HTMLElement} formatEl - The format/line element
155
+ * @param {'front'|'end'} detectedEdge - The edge detected by logical offset check
156
+ * @param {Document} doc - The document object
157
+ * @returns {boolean} true if the detected edge doesn't match the visual position (bidi mismatch)
158
+ */
159
+ function isRtlBidiMismatch(range, formatEl, detectedEdge, doc) {
160
+ if (!range.collapsed || !formatEl) return false;
161
+
162
+ const caretRect = range.getBoundingClientRect();
163
+ if (caretRect.height <= 0) return false;
164
+
165
+ const contentRange = doc.createRange();
166
+ contentRange.selectNodeContents(formatEl);
167
+
168
+ const contentRect = contentRange.getBoundingClientRect();
169
+ if (contentRect.width <= 2) return false;
170
+
171
+ // In RTL: content left = visual end, content right = visual start
172
+ // 'front' mismatch: logically at front (offset 0) but caret at left = visual end
173
+ // 'end' mismatch: logically at end (offset=length) but caret at right = visual start
174
+ return detectedEdge === 'front' ? caretRect.left <= contentRect.left + 2 : caretRect.left >= contentRect.right - 2;
175
+ }
176
+
177
+ export { hardDelete, cleanRemovedTags, isUneditableNode, setDefaultLine, isRtlBidiMismatch };
@@ -20,6 +20,10 @@ import { A } from '../actions';
20
20
  export function reduceArrowDown(actions, ports, ctx) {
21
21
  const { component } = ports;
22
22
  const { formatEl, range, selectionNode, keyCode } = ctx;
23
+ const rtl = ctx.options.get('_rtl');
24
+
25
+ // In RTL, ArrowLeft is forward (toward end on the left), ArrowRight is backward (toward start on the right)
26
+ const hDir = keyCode === 'ArrowLeft' ? (rtl ? 'forward' : 'back') : keyCode === 'ArrowRight' ? (rtl ? 'back' : 'forward') : null;
23
27
 
24
28
  // next component
25
29
  let cmponentInfo = null;
@@ -30,12 +34,24 @@ export function reduceArrowDown(actions, ports, ctx) {
30
34
  }
31
35
  break;
32
36
  case 'ArrowLeft' /** left key */:
33
- if (dom.check.isEdgePoint(selectionNode, range.startOffset, 'front')) {
34
- const prevEl = selectionNode.previousElementSibling || dom.query.getPreviousDeepestNode(selectionNode);
35
- if (prevEl) {
36
- if (component.is(prevEl)) cmponentInfo = component.get(prevEl);
37
- } else if (component.is(formatEl.previousElementSibling)) {
38
- cmponentInfo = component.get(formatEl.previousElementSibling);
37
+ case 'ArrowRight' /** right key */:
38
+ if (hDir === 'forward') {
39
+ if (dom.check.isEdgePoint(selectionNode, range.endOffset, 'end')) {
40
+ const nextEl = selectionNode.nextElementSibling || dom.query.getNextDeepestNode(selectionNode);
41
+ if (nextEl) {
42
+ if (component.is(nextEl)) cmponentInfo = component.get(nextEl);
43
+ } else if (component.is(formatEl.nextElementSibling)) {
44
+ cmponentInfo = component.get(formatEl.nextElementSibling);
45
+ }
46
+ }
47
+ } else if (hDir === 'back') {
48
+ if (dom.check.isEdgePoint(selectionNode, range.startOffset, 'front')) {
49
+ const prevEl = selectionNode.previousElementSibling || dom.query.getPreviousDeepestNode(selectionNode);
50
+ if (prevEl) {
51
+ if (component.is(prevEl)) cmponentInfo = component.get(prevEl);
52
+ } else if (component.is(formatEl.previousElementSibling)) {
53
+ cmponentInfo = component.get(formatEl.previousElementSibling);
54
+ }
39
55
  }
40
56
  }
41
57
  break;
@@ -44,16 +60,6 @@ export function reduceArrowDown(actions, ports, ctx) {
44
60
  cmponentInfo = component.get(formatEl.nextElementSibling);
45
61
  }
46
62
  break;
47
- case 'ArrowRight' /** right key */:
48
- if (dom.check.isEdgePoint(selectionNode, range.endOffset, 'end')) {
49
- const nextEl = selectionNode.nextElementSibling || dom.query.getNextDeepestNode(selectionNode);
50
- if (nextEl) {
51
- if (component.is(nextEl)) cmponentInfo = component.get(nextEl);
52
- } else if (component.is(formatEl.nextElementSibling)) {
53
- cmponentInfo = component.get(formatEl.nextElementSibling);
54
- }
55
- }
56
- break;
57
63
  }
58
64
 
59
65
  if (cmponentInfo && !cmponentInfo.options?.isInputComponent) {
@@ -1,5 +1,5 @@
1
1
  import { dom } from '../../../helper';
2
- import { cleanRemovedTags, hardDelete, isUneditableNode, setDefaultLine } from '../effects/ruleHelpers';
2
+ import { cleanRemovedTags, hardDelete, isUneditableNode, setDefaultLine, isRtlBidiMismatch } from '../effects/ruleHelpers';
3
3
  import { A } from '../actions';
4
4
 
5
5
  /**
@@ -22,6 +22,8 @@ export function reduceBackspaceDown(actions, ports, ctx) {
22
22
  let { formatEl } = ctx;
23
23
 
24
24
  const selectRange = !range.collapsed || range.startContainer !== range.endContainer;
25
+ // RTL bidi guard: if offset 0 is actually at the visual end due to LTR text in RTL line, skip front-edge handling
26
+ const bidiNotFront = options.get('_rtl') && !selectRange && range.startOffset === 0 && isRtlBidiMismatch(range, formatEl, 'front', fc.get('_wd'));
25
27
 
26
28
  actions.push(A.componentDeselect());
27
29
  actions.push(A.cacheStyleNode());
@@ -54,6 +56,7 @@ export function reduceBackspaceDown(actions, ports, ctx) {
54
56
  // closure, default
55
57
  if (
56
58
  !selectRange &&
59
+ !bidiNotFront &&
57
60
  !formatEl.previousElementSibling &&
58
61
  range.startOffset === 0 &&
59
62
  !selectionNode.previousSibling &&
@@ -87,7 +90,7 @@ export function reduceBackspaceDown(actions, ports, ctx) {
87
90
 
88
91
  // clean remove tag
89
92
  const startCon = range.startContainer;
90
- if (formatEl && !formatEl.previousElementSibling && range.startOffset === 0 && startCon.nodeType === 3 && dom.check.isZeroWidth(startCon)) {
93
+ if (formatEl && !bidiNotFront && !formatEl.previousElementSibling && range.startOffset === 0 && startCon.nodeType === 3 && dom.check.isZeroWidth(startCon)) {
91
94
  if (cleanRemovedTags(ports, startCon, formatEl) === true) return true;
92
95
  }
93
96
 
@@ -118,7 +121,7 @@ export function reduceBackspaceDown(actions, ports, ctx) {
118
121
  }
119
122
 
120
123
  // format attributes
121
- if (!selectRange && format.isEdgeLine(range.startContainer, range.startOffset, 'front')) {
124
+ if (!selectRange && !bidiNotFront && format.isEdgeLine(range.startContainer, range.startOffset, 'front')) {
122
125
  if (format.isLine(formatEl.previousElementSibling)) {
123
126
  actions.push(A.cacheFormatAttrsTemp(formatEl.previousElementSibling.attributes));
124
127
  }
@@ -134,7 +137,7 @@ export function reduceBackspaceDown(actions, ports, ctx) {
134
137
  dom.check.isList(rangeEl) &&
135
138
  (dom.check.isListCell(rangeEl.parentElement) || formatEl.previousElementSibling) &&
136
139
  (selectionNode === formatEl || (selectionNode.nodeType === 3 && (!selectionNode.previousSibling || dom.check.isList(selectionNode.previousSibling)))) &&
137
- (format.getLine(range.startContainer, null) !== format.getLine(range.endContainer, null) ? rangeEl.contains(range.startContainer) : range.startOffset === 0 && range.collapsed)
140
+ (format.getLine(range.startContainer, null) !== format.getLine(range.endContainer, null) ? rangeEl.contains(range.startContainer) : range.startOffset === 0 && range.collapsed && !bidiNotFront)
138
141
  ) {
139
142
  if (range.startContainer !== range.endContainer) {
140
143
  actions.push(A.prevent());
@@ -163,7 +166,7 @@ export function reduceBackspaceDown(actions, ports, ctx) {
163
166
  }
164
167
 
165
168
  // detach range
166
- if (!selectRange && range.startOffset === 0) {
169
+ if (!selectRange && range.startOffset === 0 && !bidiNotFront) {
167
170
  let detach = true;
168
171
  let comm = commonCon;
169
172
  while (comm && comm !== rangeEl && !dom.check.isWysiwygFrame(comm)) {
@@ -185,6 +188,16 @@ export function reduceBackspaceDown(actions, ports, ctx) {
185
188
  }
186
189
  }
187
190
 
191
+ // empty line adjacent to component (offset-independent — handles RTL caret at offset 1)
192
+ if (!selectRange && formatEl && dom.check.isEmptyLine(formatEl) && component.is(formatEl.previousElementSibling)) {
193
+ const fileComponentInfo = component.get(formatEl.previousElementSibling);
194
+ if (fileComponentInfo) {
195
+ actions.push(A.preventStop());
196
+ actions.push(A.backspaceComponentRemove(false, formatEl.firstChild, formatEl, fileComponentInfo));
197
+ return true;
198
+ }
199
+ }
200
+
188
201
  // component
189
202
  if (!selectRange && formatEl && (range.startOffset === 0 || (selectionNode === formatEl ? formatEl.childNodes[range.startOffset] : false))) {
190
203
  const isList = dom.check.isListCell(formatEl);
@@ -1,5 +1,5 @@
1
1
  import { dom } from '../../../helper';
2
- import { hardDelete, isUneditableNode } from '../effects/ruleHelpers';
2
+ import { hardDelete, isUneditableNode, isRtlBidiMismatch } from '../effects/ruleHelpers';
3
3
  import { A } from '../actions';
4
4
 
5
5
  /**
@@ -18,10 +18,12 @@ import { A } from '../actions';
18
18
  */
19
19
  export function reduceDeleteDown(actions, ports, ctx) {
20
20
  const { format, component } = ports;
21
- const { range, selectionNode } = ctx;
21
+ const { fc, range, selectionNode } = ctx;
22
22
  let { formatEl } = ctx;
23
23
 
24
24
  const selectRange = !range.collapsed || range.startContainer !== range.endContainer;
25
+ // RTL bidi guard: if offset=length is actually at the visual start due to LTR text in RTL line, skip end-edge handling
26
+ const bidiNotEnd = ctx.options.get('_rtl') && !selectRange && range.endOffset >= (range.endContainer.textContent?.length || 0) && isRtlBidiMismatch(range, formatEl, 'end', fc.get('_wd'));
25
27
 
26
28
  actions.push(A.componentDeselect());
27
29
  actions.push(A.cacheStyleNode());
@@ -31,7 +33,7 @@ export function reduceDeleteDown(actions, ports, ctx) {
31
33
  return true;
32
34
  }
33
35
 
34
- if (!selectRange && format.isEdgeLine(range.endContainer, range.endOffset, 'end') && !formatEl.nextSibling) {
36
+ if (!selectRange && !bidiNotEnd && format.isEdgeLine(range.endContainer, range.endOffset, 'end') && !formatEl.nextSibling) {
35
37
  actions.push(A.preventStop());
36
38
  return false;
37
39
  }
@@ -107,7 +109,7 @@ export function reduceDeleteDown(actions, ports, ctx) {
107
109
  }
108
110
 
109
111
  // format attributes
110
- if (!selectRange && format.isEdgeLine(range.endContainer, range.endOffset, 'end')) {
112
+ if (!selectRange && !bidiNotEnd && format.isEdgeLine(range.endContainer, range.endOffset, 'end')) {
111
113
  if (format.isLine(formatEl.nextElementSibling)) {
112
114
  actions.push(A.cacheFormatAttrsTemp(formatEl.attributes));
113
115
  }
@@ -122,7 +124,7 @@ export function reduceDeleteDown(actions, ports, ctx) {
122
124
  (selectionNode === formatEl ||
123
125
  (selectionNode.nodeType === 3 &&
124
126
  (!selectionNode.nextSibling || dom.check.isList(selectionNode.nextSibling)) &&
125
- (format.getLine(range.startContainer, null) !== format.getLine(range.endContainer, null) ? rangeEl.contains(range.endContainer) : range.endOffset === selectionNode.textContent.length && range.collapsed)))
127
+ (format.getLine(range.startContainer, null) !== format.getLine(range.endContainer, null) ? rangeEl.contains(range.endContainer) : range.endOffset === selectionNode.textContent.length && range.collapsed && !bidiNotEnd)))
126
128
  ) {
127
129
  actions.push(A.deleteListRemoveNested(range, formatEl, rangeEl));
128
130
  return true;
@@ -1,4 +1,5 @@
1
1
  import { dom } from '../../../helper';
2
+ import { isRtlBidiMismatch } from '../effects/ruleHelpers';
2
3
  import { A } from '../actions';
3
4
 
4
5
  /**
@@ -40,8 +41,24 @@ export function reduceEnterDown(actions, ports, ctx) {
40
41
  }
41
42
 
42
43
  if (!shift) {
43
- const formatEndEdge = !range.endContainer.nextSibling && format.isEdgeLine(range.endContainer, range.endOffset, 'end');
44
- const formatStartEdge = !range.startContainer.previousSibling && format.isEdgeLine(range.startContainer, range.startOffset, 'front');
44
+ let formatEndEdge = format.isEdgeLine(range.endContainer, range.endOffset, 'end');
45
+ let formatStartEdge = format.isEdgeLine(range.startContainer, range.startOffset, 'front');
46
+
47
+ // RTL bidi edge correction: when LTR text (e.g. numbers) is inside an RTL line,
48
+ // the browser places the caret at the logically opposite offset at bidi boundaries.
49
+ let bidiSwapped = false;
50
+ if (ctx.options.get('_rtl') && formatEl && range.collapsed && formatStartEdge !== formatEndEdge) {
51
+ const _wd = ctx.fc.get('_wd');
52
+ if (formatStartEdge && isRtlBidiMismatch(range, formatEl, 'front', _wd)) {
53
+ formatStartEdge = false;
54
+ formatEndEdge = true;
55
+ bidiSwapped = true;
56
+ } else if (formatEndEdge && isRtlBidiMismatch(range, formatEl, 'end', _wd)) {
57
+ formatEndEdge = false;
58
+ formatStartEdge = true;
59
+ bidiSwapped = true;
60
+ }
61
+ }
45
62
 
46
63
  // add default format line
47
64
  if (formatEndEdge && (/^H[1-6]$/i.test(formatEl.nodeName) || /^HR$/i.test(formatEl.nodeName))) {
@@ -115,7 +132,7 @@ export function reduceEnterDown(actions, ports, ctx) {
115
132
  // set format attrs - edge
116
133
  if (range.collapsed && (formatStartEdge || formatEndEdge)) {
117
134
  ports.enterPrevent(e);
118
- actions.push(A.enterFormatBreakAtEdge(formatEl, selectionNode, formatStartEdge, formatEndEdge));
135
+ actions.push(A.enterFormatBreakAtEdge(formatEl, selectionNode, formatStartEdge, formatEndEdge, bidiSwapped));
119
136
  actions.push(A.enterScrollTo(range));
120
137
  return true;
121
138
  }
@@ -125,6 +142,19 @@ export function reduceEnterDown(actions, ports, ctx) {
125
142
 
126
143
  /** @type {HTMLElement} */
127
144
  if (selectRange) {
145
+ // RTL bidi edge correction for selection ranges (formatStartEdge only)
146
+ // formatEndEdge insert-after behavior is already correct for RTL selections
147
+ if (ctx.options.get('_rtl') && formatStartEdge && !formatEndEdge) {
148
+ const _wd = ctx.fc.get('_wd');
149
+ const testRange = _wd.createRange();
150
+ testRange.setStart(range.startContainer, range.startOffset);
151
+ testRange.collapse(true);
152
+ if (isRtlBidiMismatch(testRange, formatEl, 'front', _wd)) {
153
+ formatStartEdge = false;
154
+ formatEndEdge = true;
155
+ bidiSwapped = true;
156
+ }
157
+ }
128
158
  actions.push(A.enterFormatBreakWithSelection(formatEl, range, formatStartEdge, formatEndEdge));
129
159
  } else {
130
160
  actions.push(A.enterFormatBreakAtCursor(formatEl, range));
@@ -766,7 +766,11 @@ class Format {
766
766
  * @returns {node is HTMLElement}
767
767
  */
768
768
  isEdgeLine(node, offset, dir) {
769
- if (!dom.check.isEdgePoint(node, offset, dir)) return false;
769
+ if (dir === 'front') {
770
+ if (offset > 0) return false;
771
+ } else {
772
+ if (node?.textContent?.length > offset) return false;
773
+ }
770
774
 
771
775
  let result = false;
772
776
  const siblingType = dir === 'front' ? 'previousSibling' : 'nextSibling';
@@ -1,4 +1,6 @@
1
- import { dom, converter } from '../../../helper';
1
+ import { dom, converter, env } from '../../../helper';
2
+
3
+ const { isMobile, _w } = env;
2
4
 
3
5
  /**
4
6
  * @description Dropdown and container menu management class
@@ -22,6 +24,9 @@ class Menu {
22
24
  #bindMenu_mouseout = null;
23
25
  #menuBtn = null;
24
26
  #menuContainer = null;
27
+ #deferredShowTimer = null;
28
+ #viewportListener = null;
29
+ #visualViewport = null;
25
30
 
26
31
  /**
27
32
  * @constructor
@@ -67,6 +72,7 @@ class Menu {
67
72
  // eventManager member (viewport)
68
73
  this.#menuBtn = null;
69
74
  this.#menuContainer = null;
75
+ this.#visualViewport = _w.visualViewport || null;
70
76
  }
71
77
 
72
78
  /**
@@ -109,7 +115,12 @@ class Menu {
109
115
  this.currentDropdownType = btnEl.getAttribute('data-type');
110
116
  const menu = (this.currentDropdown = this.targetMap[dropdownName]);
111
117
  this.currentDropdownActiveButton = btnEl;
112
- this.#setMenuPosition(btnEl, menu);
118
+
119
+ if (isMobile) {
120
+ this.#deferMenuShow(btnEl, menu);
121
+ } else {
122
+ this.#setMenuPosition(btnEl, menu);
123
+ }
113
124
 
114
125
  this.#bindClose_dropdown_mouse = this.#eventManager.addGlobalEvent('mousedown', this.#globalEventHandler.mousedown, false);
115
126
  if (this.#dropdownCommands.includes(dropdownName)) {
@@ -131,6 +142,7 @@ class Menu {
131
142
  * @description Closes the currently open dropdown menu.
132
143
  */
133
144
  dropdownOff() {
145
+ this.#clearDeferredShow();
134
146
  this.#removeGlobalEvent();
135
147
  if (IsFree(this.currentDropdownType)) this.currentDropdownPlugin?.off?.();
136
148
 
@@ -189,7 +201,13 @@ class Menu {
189
201
 
190
202
  this.currentContainerActiveButton = /** @type {HTMLButtonElement} */ (button);
191
203
  const containerName = (this.currentContainerName = this.currentContainerActiveButton.getAttribute('data-command'));
192
- this.#setMenuPosition(button, (this.currentContainer = this.targetMap[containerName]));
204
+ this.currentContainer = this.targetMap[containerName];
205
+
206
+ if (isMobile) {
207
+ this.#deferMenuShow(button, this.currentContainer);
208
+ } else {
209
+ this.#setMenuPosition(button, this.currentContainer);
210
+ }
193
211
 
194
212
  this.#bindClose_cons_mouse = this.#eventManager.addGlobalEvent('mousedown', this.#globalEventHandler.containerDown, false);
195
213
 
@@ -201,6 +219,7 @@ class Menu {
201
219
  * @description Closes the currently open menu container.
202
220
  */
203
221
  containerOff() {
222
+ this.#clearDeferredShow();
204
223
  this.#removeGlobalEvent();
205
224
 
206
225
  if (this.currentContainer) {
@@ -231,6 +250,8 @@ class Menu {
231
250
  */
232
251
  __restoreMenuPosition() {
233
252
  if (!this.#menuBtn || !this.#menuContainer) return;
253
+ // skip if deferred show is pending — it will handle positioning after viewport settles
254
+ if (this.#viewportListener || this.#deferredShowTimer) return;
234
255
  this.#setMenuPosition(this.#menuBtn, this.#menuContainer);
235
256
  }
236
257
 
@@ -253,6 +274,56 @@ class Menu {
253
274
  this.#menuContainer = menu;
254
275
  }
255
276
 
277
+ /**
278
+ * @description Defer menu display on mobile until viewport settles after keyboard dismiss.
279
+ * @param {Node} element Button element
280
+ * @param {HTMLElement} menu Menu element
281
+ */
282
+ #deferMenuShow(element, menu) {
283
+ this.#clearDeferredShow();
284
+
285
+ menu.style.display = 'none';
286
+ dom.utils.addClass(element.parentElement.children, 'on');
287
+ this.#menuBtn = element;
288
+ this.#menuContainer = menu;
289
+
290
+ let resolved = false;
291
+ const show = (delay) => {
292
+ if (resolved) return;
293
+ resolved = true;
294
+ this.#clearDeferredShow();
295
+ this.#deferredShowTimer = _w.setTimeout(() => {
296
+ this.#deferredShowTimer = null;
297
+ if (this.#menuBtn === element) {
298
+ this.#setMenuPosition(element, menu);
299
+ }
300
+ }, delay);
301
+ };
302
+
303
+ if (this.#visualViewport) {
304
+ // listen for viewport resize (keyboard dismiss) — small delay to let viewport settle
305
+ this.#viewportListener = () => show(10);
306
+ this.#visualViewport.addEventListener('resize', this.#viewportListener, { once: true });
307
+ }
308
+
309
+ // fallback if no viewport change occurs (keyboard already hidden or no visualViewport)
310
+ this.#deferredShowTimer = _w.setTimeout(() => show(0), 50);
311
+ }
312
+
313
+ /**
314
+ * @description Clear deferred show timer and viewport listener.
315
+ */
316
+ #clearDeferredShow() {
317
+ if (this.#deferredShowTimer) {
318
+ _w.clearTimeout(this.#deferredShowTimer);
319
+ this.#deferredShowTimer = null;
320
+ }
321
+ if (this.#viewportListener) {
322
+ this.#visualViewport?.removeEventListener('resize', this.#viewportListener);
323
+ this.#viewportListener = null;
324
+ }
325
+ }
326
+
256
327
  /**
257
328
  * @description Check if the element is part of a more layer
258
329
  * @param {Node} element The element to check