suneditor 3.1.1 → 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.1",
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 = format.isEdgeLine(range.endContainer, range.endOffset, 'end');
44
- const formatStartEdge = 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));
@@ -200,14 +200,27 @@ class UIManager {
200
200
 
201
201
  /**
202
202
  * @description Set direction to `rtl` or `ltr`.
203
- * @param {string} dir `rtl` or `ltr`
203
+ * @param {"rtl"|"ltr"} dir `rtl` or `ltr`
204
204
  */
205
205
  setDir(dir) {
206
206
  const rtl = dir === 'rtl';
207
207
  if (this.#options.get('_rtl') === rtl) return;
208
208
 
209
+ const prevDir = this.#options.get('textDirection');
210
+ const prevEditableClass = this.#options.get('_editableClass');
211
+ const prevPrintClass = this.#options.get('printClass');
212
+
209
213
  try {
210
214
  this.#options.set('_rtl', rtl);
215
+ this.#options.set('textDirection', dir);
216
+
217
+ // update _editableClass / printClass
218
+ const editableClass = rtl ? this.#options.get('_editableClass').replace(/\s*se-rtl/, '') + ' se-rtl' : this.#options.get('_editableClass').replace(/\s*se-rtl/, '');
219
+ this.#options.set('_editableClass', editableClass);
220
+ if (this.#options.get('printClass')) {
221
+ this.#options.set('printClass', rtl ? this.#options.get('printClass').replace(/\s*se-rtl/, '') + ' se-rtl' : this.#options.get('printClass').replace(/\s*se-rtl/, ''));
222
+ }
223
+
211
224
  this.offCurrentController();
212
225
 
213
226
  const fc = this.#frameContext;
@@ -221,11 +234,13 @@ class UIManager {
221
234
  if (rtl) {
222
235
  this.#contextProvider.applyToRoots((e) => {
223
236
  dom.utils.addClass([e.get('topArea'), e.get('wysiwyg'), e.get('documentTypePageMirror')], 'se-rtl');
237
+ e.get('wysiwyg').dir = 'rtl';
224
238
  });
225
239
  dom.utils.addClass([this.#carrierWrapper, toolbarWrapper, statusbarWrapper], 'se-rtl');
226
240
  } else {
227
241
  this.#contextProvider.applyToRoots((e) => {
228
242
  dom.utils.removeClass([e.get('topArea'), e.get('wysiwyg'), e.get('documentTypePageMirror')], 'se-rtl');
243
+ e.get('wysiwyg').removeAttribute('dir');
229
244
  });
230
245
  dom.utils.removeClass([this.#carrierWrapper, toolbarWrapper, statusbarWrapper], 'se-rtl');
231
246
  }
@@ -255,6 +270,12 @@ class UIManager {
255
270
 
256
271
  this.#activeDirBtn(rtl);
257
272
 
273
+ // reverse toolbar buttons
274
+ this.#reverseToolbarButtons(this.#context.get('toolbar_buttonTray'));
275
+ if (this.#context.has('toolbar_sub_buttonTray')) {
276
+ this.#reverseToolbarButtons(this.#context.get('toolbar_sub_buttonTray'));
277
+ }
278
+
258
279
  // document type
259
280
  if (fc.has('documentType_use_header')) {
260
281
  if (rtl) fc.get('wrapper').appendChild(fc.get('documentTypeInner'));
@@ -269,6 +290,9 @@ class UIManager {
269
290
  else if (this.#store.mode.isSubBalloon) this.#$.subToolbar._showBalloon();
270
291
  } catch (e) {
271
292
  this.#options.set('_rtl', !rtl);
293
+ this.#options.set('textDirection', prevDir);
294
+ this.#options.set('_editableClass', prevEditableClass);
295
+ if (prevPrintClass !== null) this.#options.set('printClass', prevPrintClass);
272
296
  console.warn(`[SUNEDITOR.ui.setDir.fail] ${e.toString()}`);
273
297
  }
274
298
 
@@ -702,6 +726,20 @@ class UIManager {
702
726
  }
703
727
  }
704
728
 
729
+ /**
730
+ * @description Reverse the order of toolbar button groups (excluding the more-layer).
731
+ * @param {HTMLElement} buttonTray - The `.se-btn-tray` element.
732
+ */
733
+ #reverseToolbarButtons(buttonTray) {
734
+ if (!buttonTray) return;
735
+ const moreLayer = buttonTray.querySelector('.se-toolbar-more-layer');
736
+ const children = Array.from(buttonTray.children).filter((c) => c !== moreLayer);
737
+ for (let i = children.length - 1; i >= 0; i--) {
738
+ buttonTray.appendChild(children[i]);
739
+ }
740
+ if (moreLayer) buttonTray.appendChild(moreLayer);
741
+ }
742
+
705
743
  /**
706
744
  * @internal
707
745
  * @description Set the disabled button list
@@ -361,8 +361,21 @@ export function CreateShortcuts(command, button, values, keyMap, rc, reverseKeys
361
361
  }
362
362
  }
363
363
 
364
+ /**
365
+ * @description Append tooltip span
366
+ * @param {Element} tooptipBtn
367
+ * @param {boolean} shift
368
+ * @param {string} shortcut
369
+ */
364
370
  function _addTooltip(tooptipBtn, shift, shortcut) {
365
- tooptipBtn.appendChild(dom.utils.createElement('SPAN', { class: 'se-shortcut' }, env.cmdIcon + (shift ? env.shiftIcon : '') + '+<span class="se-shortcut-key">' + shortcut + '</span>'));
371
+ const tooltip = dom.utils.createElement('SPAN', { class: 'se-shortcut' }, env.cmdIcon + (shift ? env.shiftIcon : '') + '+<span class="se-shortcut-key">' + shortcut + '</span>');
372
+ const prevTooltip = tooptipBtn.querySelector('.se-shortcut');
373
+
374
+ if (prevTooltip) {
375
+ tooptipBtn.replaceChild(tooltip, prevTooltip);
376
+ } else {
377
+ tooptipBtn.appendChild(tooltip);
378
+ }
366
379
  }
367
380
 
368
381
  /**
@@ -937,6 +950,7 @@ function _initTargetElements(key, options, topDiv, targetOptions) {
937
950
 
938
951
  if (!targetOptions.get('iframe')) {
939
952
  wysiwygDiv.setAttribute('contenteditable', 'true');
953
+ if (options.get('_rtl')) wysiwygDiv.dir = 'rtl';
940
954
  wysiwygDiv.className += ' ' + options.get('_editableClass');
941
955
  wysiwygDiv.style.cssText = editorStyles.frame + editorStyles.editor;
942
956
  } else {
@@ -31,7 +31,7 @@ export namespace A {
31
31
  function enterFormatCleanBrAndZWS(selectionNode: Node, selectionFormat: boolean, brBlock: Element, children: NodeList, offset: number): Action;
32
32
  function enterFormatInsertBrHtml(brBlock: Element, range: Range, wSelection: Selection, offset: number): Action;
33
33
  function enterFormatInsertBrNode(wSelection: Selection): Action;
34
- function enterFormatBreakAtEdge(formatEl: Element, selectionNode: Node, formatStartEdge: boolean, formatEndEdge: boolean): Action;
34
+ function enterFormatBreakAtEdge(formatEl: Element, selectionNode: Node, formatStartEdge: boolean, formatEndEdge: boolean, bidiSwapped?: boolean): Action;
35
35
  function enterFormatBreakWithSelection(formatEl: Element, range: Range, formatStartEdge: boolean, formatEndEdge: boolean): Action;
36
36
  function enterFormatBreakAtCursor(formatEl: Element, range: Range): Action;
37
37
  function enterFigcaptionExitInList(formatEl: Element): Action;
@@ -42,7 +42,7 @@ declare const _default: {
42
42
  /** @action enterFormatInsertBrNode */
43
43
  'enter.format.insertBrNode': ({ ports }: EffectContext_keydown, { wSelection }: any) => void;
44
44
  /** @action enterFormatBreakAtEdge */
45
- 'enter.format.breakAtEdge': ({ ports, ctx }: EffectContext_keydown, { formatEl, selectionNode, formatStartEdge, formatEndEdge }: any) => void;
45
+ 'enter.format.breakAtEdge': ({ ports, ctx }: EffectContext_keydown, { formatEl, selectionNode, formatStartEdge, formatEndEdge, bidiSwapped }: any) => void;
46
46
  /** @action enterFormatBreakWithSelection */
47
47
  'enter.format.breakWithSelection': ({ ports, ctx }: EffectContext_keydown, { formatEl, range, formatStartEdge, formatEndEdge }: any) => void;
48
48
  /** @action enterFormatBreakAtCursor */
@@ -34,3 +34,15 @@ export function isUneditableNode(ports: EventPorts, range: Range, isFront: boole
34
34
  * @returns {void}
35
35
  */
36
36
  export function setDefaultLine(ports: EventPorts, lineTagName: string): void;
37
+ /**
38
+ * @description Detects if a detected logical edge is incorrect due to bidi text direction mismatch in RTL mode.
39
+ * When LTR text (numbers, Latin) is inside an RTL line, the browser may place the caret at offset 0
40
+ * for the visual end or offset=length for the visual start. This function compares the caret's visual
41
+ * position against the content boundaries to detect such mismatches.
42
+ * @param {Range} range - The current collapsed range
43
+ * @param {HTMLElement} formatEl - The format/line element
44
+ * @param {'front'|'end'} detectedEdge - The edge detected by logical offset check
45
+ * @param {Document} doc - The document object
46
+ * @returns {boolean} true if the detected edge doesn't match the visual position (bidi mismatch)
47
+ */
48
+ export function isRtlBidiMismatch(range: Range, formatEl: HTMLElement, detectedEdge: 'front' | 'end', doc: Document): boolean;