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.
@@ -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 {
@@ -59,11 +59,48 @@ function defaultOnSelect(item, triggerText) {
59
59
  * @property {boolean} [useCachingFieldData=true] - Whether to cache selected items for priority display.
60
60
  * @property {Object<string, AutocompleteTriggerConfig>} triggers - Per-trigger configurations keyed by trigger character.
61
61
  * ```js
62
- * // triggers
63
- * {
64
- * '@': { data: [...], renderItem: (item) => `...` },
65
- * '#': { apiUrl: '/api/tags?q={key}' }
66
- * }
62
+ * // Basic usage with static data — mention trigger
63
+ * const editor = SUNEDITOR.create('#editor', {
64
+ * plugins: [autocomplete],
65
+ * autocomplete: {
66
+ * triggers: {
67
+ * '@': {
68
+ * data: [
69
+ * { key: 'john', name: 'John Doe' },
70
+ * { key: 'jane', name: 'Jane Smith' },
71
+ * ],
72
+ * },
73
+ * },
74
+ * },
75
+ * });
76
+ *
77
+ * // API-based trigger with custom rendering and selection
78
+ * const editor = SUNEDITOR.create('#editor', {
79
+ * plugins: [autocomplete],
80
+ * autocomplete: {
81
+ * delayTime: 200,
82
+ * limitSize: 10,
83
+ * triggers: {
84
+ * '@': {
85
+ * apiUrl: '/api/users?q={key}&limit={limitSize}',
86
+ * apiHeaders: { Authorization: 'Bearer TOKEN' },
87
+ * transformResponse: (json) => json.data.map((u) => ({ key: u.username, name: u.displayName, id: u.id })),
88
+ * renderItem: (item) => `<div class="user-item"><strong>${item.key}</strong> <span>${item.name}</span></div>`,
89
+ * onSelect: (item, trigger) => ({
90
+ * tag: 'a',
91
+ * attrs: { href: `/users/${item.id}`, 'data-se-autocomplete': trigger + item.key },
92
+ * text: trigger + item.key,
93
+ * }),
94
+ * },
95
+ * '#': {
96
+ * apiUrl: '/api/tags?q={key}',
97
+ * transformResponse: (json) => json.tags,
98
+ * searchStartLength: 2,
99
+ * useCachingData: false,
100
+ * },
101
+ * },
102
+ * },
103
+ * });
67
104
  * ```
68
105
  */
69
106
 
@@ -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;
@@ -51,9 +51,9 @@ declare class UIManager {
51
51
  setTheme(theme: string): void;
52
52
  /**
53
53
  * @description Set direction to `rtl` or `ltr`.
54
- * @param {string} dir `rtl` or `ltr`
54
+ * @param {"rtl"|"ltr"} dir `rtl` or `ltr`
55
55
  */
56
- setDir(dir: string): void;
56
+ setDir(dir: 'rtl' | 'ltr'): void;
57
57
  /**
58
58
  * @description Switch to or off `ReadOnly` mode.
59
59
  * @param {boolean} value `readOnly` boolean value.
@@ -102,11 +102,48 @@ export type AutocompletePluginOptions = {
102
102
  /**
103
103
  * - Per-trigger configurations keyed by trigger character.
104
104
  * ```js
105
- * // triggers
106
- * {
107
- * '@': { data: [...], renderItem: (item) => `...` },
108
- * '#': { apiUrl: '/api/tags?q={key}' }
109
- * }
105
+ * // Basic usage with static data — mention trigger
106
+ * const editor = SUNEDITOR.create('#editor', {
107
+ * plugins: [autocomplete],
108
+ * autocomplete: {
109
+ * triggers: {
110
+ * '@': {
111
+ * data: [
112
+ * { key: 'john', name: 'John Doe' },
113
+ * { key: 'jane', name: 'Jane Smith' },
114
+ * ],
115
+ * },
116
+ * },
117
+ * },
118
+ * });
119
+ *
120
+ * // API-based trigger with custom rendering and selection
121
+ * const editor = SUNEDITOR.create('#editor', {
122
+ * plugins: [autocomplete],
123
+ * autocomplete: {
124
+ * delayTime: 200,
125
+ * limitSize: 10,
126
+ * triggers: {
127
+ * '@': {
128
+ * apiUrl: '/api/users?q={key}&limit={limitSize}',
129
+ * apiHeaders: { Authorization: 'Bearer TOKEN' },
130
+ * transformResponse: (json) => json.data.map((u) => ({ key: u.username, name: u.displayName, id: u.id })),
131
+ * renderItem: (item) => `<div class="user-item"><strong>${item.key}</strong> <span>${item.name}</span></div>`,
132
+ * onSelect: (item, trigger) => ({
133
+ * tag: 'a',
134
+ * attrs: { href: `/users/${item.id}`, 'data-se-autocomplete': trigger + item.key },
135
+ * text: trigger + item.key,
136
+ * }),
137
+ * },
138
+ * '#': {
139
+ * apiUrl: '/api/tags?q={key}',
140
+ * transformResponse: (json) => json.tags,
141
+ * searchStartLength: 2,
142
+ * useCachingData: false,
143
+ * },
144
+ * },
145
+ * },
146
+ * });
110
147
  * ```
111
148
  */
112
149
  triggers: {
@@ -142,11 +179,48 @@ export type AutocompletePluginOptions = {
142
179
  * @property {boolean} [useCachingFieldData=true] - Whether to cache selected items for priority display.
143
180
  * @property {Object<string, AutocompleteTriggerConfig>} triggers - Per-trigger configurations keyed by trigger character.
144
181
  * ```js
145
- * // triggers
146
- * {
147
- * '@': { data: [...], renderItem: (item) => `...` },
148
- * '#': { apiUrl: '/api/tags?q={key}' }
149
- * }
182
+ * // Basic usage with static data — mention trigger
183
+ * const editor = SUNEDITOR.create('#editor', {
184
+ * plugins: [autocomplete],
185
+ * autocomplete: {
186
+ * triggers: {
187
+ * '@': {
188
+ * data: [
189
+ * { key: 'john', name: 'John Doe' },
190
+ * { key: 'jane', name: 'Jane Smith' },
191
+ * ],
192
+ * },
193
+ * },
194
+ * },
195
+ * });
196
+ *
197
+ * // API-based trigger with custom rendering and selection
198
+ * const editor = SUNEDITOR.create('#editor', {
199
+ * plugins: [autocomplete],
200
+ * autocomplete: {
201
+ * delayTime: 200,
202
+ * limitSize: 10,
203
+ * triggers: {
204
+ * '@': {
205
+ * apiUrl: '/api/users?q={key}&limit={limitSize}',
206
+ * apiHeaders: { Authorization: 'Bearer TOKEN' },
207
+ * transformResponse: (json) => json.data.map((u) => ({ key: u.username, name: u.displayName, id: u.id })),
208
+ * renderItem: (item) => `<div class="user-item"><strong>${item.key}</strong> <span>${item.name}</span></div>`,
209
+ * onSelect: (item, trigger) => ({
210
+ * tag: 'a',
211
+ * attrs: { href: `/users/${item.id}`, 'data-se-autocomplete': trigger + item.key },
212
+ * text: trigger + item.key,
213
+ * }),
214
+ * },
215
+ * '#': {
216
+ * apiUrl: '/api/tags?q={key}',
217
+ * transformResponse: (json) => json.tags,
218
+ * searchStartLength: 2,
219
+ * useCachingData: false,
220
+ * },
221
+ * },
222
+ * },
223
+ * });
150
224
  * ```
151
225
  */
152
226
  /**