suneditor 3.0.6 → 3.1.1

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 (61) 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 +2 -2
  5. package/src/core/editor.js +20 -3
  6. package/src/core/event/eventOrchestrator.js +2 -1
  7. package/src/core/event/handlers/handler_ww_key.js +2 -2
  8. package/src/core/event/rules/keydown.rule.enter.js +2 -2
  9. package/src/core/logic/dom/format.js +5 -1
  10. package/src/core/logic/dom/html.js +23 -1
  11. package/src/core/logic/dom/offset.js +24 -1
  12. package/src/core/logic/panel/menu.js +74 -3
  13. package/src/core/logic/panel/viewer.js +6 -4
  14. package/src/core/logic/shell/shortcuts.js +1 -1
  15. package/src/core/schema/options.js +1 -1
  16. package/src/core/section/constructor.js +2 -2
  17. package/src/helper/index.js +3 -0
  18. package/src/helper/msOffice.js +849 -0
  19. package/src/interfaces/plugins.js +1 -1
  20. package/src/langs/ckb.js +1 -0
  21. package/src/langs/cs.js +1 -0
  22. package/src/langs/da.js +1 -0
  23. package/src/langs/de.js +1 -0
  24. package/src/langs/en.js +1 -1
  25. package/src/langs/es.js +1 -0
  26. package/src/langs/fa.js +1 -0
  27. package/src/langs/fr.js +1 -0
  28. package/src/langs/he.js +1 -0
  29. package/src/langs/hu.js +1 -0
  30. package/src/langs/it.js +1 -0
  31. package/src/langs/ja.js +1 -0
  32. package/src/langs/km.js +1 -0
  33. package/src/langs/ko.js +1 -0
  34. package/src/langs/lv.js +1 -0
  35. package/src/langs/nl.js +1 -0
  36. package/src/langs/pl.js +1 -0
  37. package/src/langs/pt_br.js +1 -0
  38. package/src/langs/ro.js +1 -0
  39. package/src/langs/ru.js +1 -0
  40. package/src/langs/se.js +1 -0
  41. package/src/langs/tr.js +1 -0
  42. package/src/langs/uk.js +1 -0
  43. package/src/langs/ur.js +1 -0
  44. package/src/langs/zh_cn.js +1 -0
  45. package/src/modules/contract/Browser.js +1 -0
  46. package/src/plugins/dropdown/layout.js +1 -1
  47. package/src/plugins/dropdown/template.js +2 -1
  48. package/src/plugins/field/autocomplete.js +383 -0
  49. package/src/plugins/index.js +3 -3
  50. package/src/typedef.js +1 -1
  51. package/types/core/logic/shell/shortcuts.d.ts +2 -2
  52. package/types/core/schema/options.d.ts +2 -2
  53. package/types/helper/index.d.ts +4 -0
  54. package/types/helper/msOffice.d.ts +11 -0
  55. package/types/interfaces/plugins.d.ts +1 -1
  56. package/types/langs/_Lang.d.ts +1 -2
  57. package/types/plugins/field/autocomplete.d.ts +251 -0
  58. package/types/plugins/index.d.ts +3 -3
  59. package/types/typedef.d.ts +1 -1
  60. package/src/plugins/field/mention.js +0 -251
  61. package/types/plugins/field/mention.d.ts +0 -104
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suneditor",
3
- "version": "3.0.6",
3
+ "version": "3.1.1",
4
4
  "description": "Vanilla JavaScript based WYSIWYG web editor",
5
5
  "author": "Yi JiHong",
6
6
  "license": "MIT",
@@ -4377,8 +4377,8 @@
4377
4377
  border-style: dashed none none;
4378
4378
  }
4379
4379
 
4380
- /** mention */
4381
- .sun-editor .se-select-menu .se-select-item .se-mention-item span + span {
4380
+ /** autocomplete */
4381
+ .sun-editor .se-select-menu .se-select-item .se-autocomplete-item span + span {
4382
4382
  margin: 0 4px;
4383
4383
  opacity: 0.5;
4384
4384
  }
@@ -267,10 +267,27 @@ class Editor {
267
267
 
268
268
  if (e.get('options').get('iframe')) {
269
269
  const iframeLoaded = new Promise((resolve) => {
270
- this.$.eventManager.addEvent(e.get('wysiwygFrame'), 'load', ({ target }) => {
271
- this.#setIframeDocument(/** @type{HTMLIFrameElement} */ (target), this.$.optionProvider.options, e.get('options'));
270
+ const setupIframe = (target) => {
271
+ this.#setIframeDocument(target, this.$.optionProvider.options, e.get('options'));
272
272
  resolve();
273
- });
273
+ };
274
+
275
+ if (env.isGecko) {
276
+ // Firefox fires the iframe "load" event twice for sandboxed about:blank iframes —
277
+ // once for the initial document and again after sandbox processing replaces it.
278
+ // Debounce to ensure we initialize against the final document.
279
+ let debounceTimer = null;
280
+ this.$.eventManager.addEvent(e.get('wysiwygFrame'), 'load', ({ target }) => {
281
+ clearTimeout(debounceTimer);
282
+ debounceTimer = setTimeout(() => {
283
+ if (this.$) setupIframe(target);
284
+ }, 60);
285
+ });
286
+ } else {
287
+ this.$.eventManager.addEvent(e.get('wysiwygFrame'), 'load', ({ target }) => {
288
+ setupIframe(target);
289
+ });
290
+ }
274
291
  });
275
292
  iframePromises.push(iframeLoaded);
276
293
  }
@@ -1,5 +1,5 @@
1
1
  import KernelInjector from '../kernel/kernelInjector';
2
- import { dom, unicode, numbers, env, converter } from '../../helper';
2
+ import { dom, unicode, numbers, env, converter, msOffice } from '../../helper';
3
3
  import { _DragHandle } from '../../modules/ui';
4
4
 
5
5
  // event handlers
@@ -256,6 +256,7 @@ class EventOrchestrator extends KernelInjector {
256
256
  if (MSData) {
257
257
  cleanData = cleanData.replace(/\n/g, ' ');
258
258
  plainText = plainText.replace(/\n/g, ' ');
259
+ cleanData = msOffice.cleanHTML(cleanData);
259
260
  }
260
261
  }
261
262
 
@@ -42,9 +42,9 @@ export async function OnKeyDown_wysiwyg(fc, e) {
42
42
  this.$.menu.dropdownOff();
43
43
 
44
44
  if (this.$.store.mode.isBalloon) {
45
- this._hideToolbar();
45
+ if (!this.$.store.mode.isBalloonAlways) this._hideToolbar();
46
46
  } else if (this.$.store.mode.isSubBalloon) {
47
- this._hideToolbar_sub();
47
+ if (!this.$.store.mode.isSubBalloonAlways) this._hideToolbar_sub();
48
48
  }
49
49
 
50
50
  // user event
@@ -40,8 +40,8 @@ export function reduceEnterDown(actions, ports, ctx) {
40
40
  }
41
41
 
42
42
  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');
43
+ const formatEndEdge = format.isEdgeLine(range.endContainer, range.endOffset, 'end');
44
+ const formatStartEdge = format.isEdgeLine(range.startContainer, range.startOffset, 'front');
45
45
 
46
46
  // add default format line
47
47
  if (formatEndEdge && (/^H[1-6]$/i.test(formatEl.nodeName) || /^HR$/i.test(formatEl.nodeName))) {
@@ -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';
@@ -1648,12 +1648,34 @@ class HTML {
1648
1648
 
1649
1649
  for (let i = 0, len = withoutFormatCells.length, t, f; i < len; i++) {
1650
1650
  t = withoutFormatCells[i];
1651
+
1651
1652
  f = dom.utils.createElement('DIV');
1652
- f.innerHTML = t.textContent.trim().length === 0 && t.children.length === 0 ? '<br>' : t.innerHTML;
1653
+ f.innerHTML = t.innerHTML;
1654
+
1655
+ if (t.textContent.trim().length === 0 && this.#isAllTextStyleNodes(t)) {
1656
+ let leaf = /** @type {Element} */ (f);
1657
+ while (leaf.firstElementChild) leaf = leaf.firstElementChild;
1658
+ leaf.innerHTML = '<br>';
1659
+ }
1660
+
1653
1661
  t.innerHTML = f.outerHTML;
1654
1662
  }
1655
1663
  }
1656
1664
 
1665
+ /**
1666
+ * @description Checks if all element descendants are text-style nodes (no focusable non-text elements like br, img, etc.).
1667
+ * @param {Element} el Target element
1668
+ * @returns {boolean}
1669
+ */
1670
+ #isAllTextStyleNodes(el) {
1671
+ const nodes = el.querySelectorAll('*');
1672
+ const format = this.#$.format;
1673
+ for (let i = 0; i < nodes.length; i++) {
1674
+ if (!format.isTextStyleNode(nodes[i])) return false;
1675
+ }
1676
+ return true;
1677
+ }
1678
+
1657
1679
  /**
1658
1680
  * @description Removes attribute values such as style and converts tags that do not conform to the `html5` standard.
1659
1681
  * @param {string} html HTML string
@@ -1,8 +1,9 @@
1
1
  import { getParentElement } from '../../../helper/dom/domQuery';
2
2
  import { isWysiwygFrame, isElement } from '../../../helper/dom/domCheck';
3
- import { hasClass, addClass, removeClass, getClientSize } from '../../../helper/dom/domUtils';
3
+ import { hasClass, addClass, removeClass, getClientSize, createTextNode } from '../../../helper/dom/domUtils';
4
4
  import { numbers } from '../../../helper';
5
5
  import { _w, _d } from '../../../helper/env';
6
+ import { zeroWidthSpace } from '../../../helper/unicode';
6
7
 
7
8
  /**
8
9
  * @typedef {Object} RectsInfo Bounding rectangle information of the selection range.
@@ -639,6 +640,28 @@ class Offset {
639
640
  const rectsObj = this.#$.selection.getRects(range, positionTop ? 'start' : 'end');
640
641
  positionTop = rectsObj.position === 'start';
641
642
 
643
+ // fallback: when rects could not be obtained from the range (e.g. collapsed at <br>),
644
+ // insert a temporary zero-width space to get accurate viewport coordinates.
645
+ if (rectsObj.rects.noText) {
646
+ let refNode = range.startContainer;
647
+ if (refNode.nodeType === 1) refNode = refNode.childNodes[range.startOffset] || refNode;
648
+ const parentEl = refNode.parentNode;
649
+ if (parentEl) {
650
+ const zws = createTextNode(zeroWidthSpace);
651
+ parentEl.insertBefore(zws, refNode);
652
+ const tempRange = _d.createRange();
653
+ tempRange.setStart(zws, 1);
654
+ tempRange.setEnd(zws, 1);
655
+ const tempRects = tempRange.getClientRects()[0];
656
+ if (tempRects) {
657
+ rectsObj.rects = tempRects;
658
+ rectsObj.scrollLeft = _w.scrollX;
659
+ rectsObj.scrollTop = _w.scrollY;
660
+ }
661
+ parentEl.removeChild(zws);
662
+ }
663
+ }
664
+
642
665
  const isFullScreen = this.#frameContext.get('isFullScreen');
643
666
  const topArea = this.#frameContext.get('topArea');
644
667
  const isInCarrier = this.#carrierWrapper.contains(element);
@@ -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
@@ -526,6 +526,8 @@ class Viewer {
526
526
  const printDocument = dom.query.getIframeDocument(iframe);
527
527
  const wDoc = this.#frameContext.get('_wd');
528
528
  const rtlClass = this.#options.get('_rtl') ? ' se-rtl' : '';
529
+ const themeClass = (this.#options.get('_themeClass') || '').trim();
530
+ const stripTheme = (cls) => (themeClass ? cls.replace(themeClass, '').trim() : cls);
529
531
  const pageCSS = /*html*/ `
530
532
  <style>
531
533
  @page {
@@ -536,10 +538,10 @@ class Viewer {
536
538
 
537
539
  if (this.#frameOptions.get('iframe')) {
538
540
  const arrts = this.#options.get('printClass')
539
- ? 'class="' + this.#options.get('printClass') + rtlClass + '"'
541
+ ? 'class="' + stripTheme(this.#options.get('printClass')) + rtlClass + '"'
540
542
  : this.#frameOptions.get('iframe_fullPage')
541
- ? dom.utils.getAttributesToString(wDoc.body, ['contenteditable'])
542
- : 'class="' + this.#options.get('_editableClass') + rtlClass + '"';
543
+ ? dom.utils.getAttributesToString(wDoc.body, ['contenteditable']).replace(themeClass, '')
544
+ : 'class="' + stripTheme(this.#options.get('_editableClass')) + rtlClass + '"';
543
545
 
544
546
  printDocument.write(/*html*/ `
545
547
  <!DOCTYPE html>
@@ -570,7 +572,7 @@ class Viewer {
570
572
  ${linkHTML}
571
573
  ${pageCSS}
572
574
  </head>
573
- <body class="${(this.#options.get('printClass') || this.#options.get('_editableClass')) + rtlClass}" style="padding: 0; padding-left: 0; padding-top: 0; padding-right: 0; padding-bottom: 0;">
575
+ <body class="${stripTheme(this.#options.get('printClass') || this.#options.get('_editableClass')) + rtlClass}" style="padding: 0; padding-left: 0; padding-top: 0; padding-right: 0; padding-bottom: 0;">
574
576
  ${contentHTML}
575
577
  </body>
576
578
  </html>`);
@@ -16,7 +16,7 @@ import { CreateShortcuts } from '../../section/constructor';
16
16
  * @property {string} type - Plugin's type. (`command`, `dropdown`, `modal`, `browser`, `input`, `field`, `popup`).
17
17
  * @property {Node} button - The plugin command button.
18
18
  * @property {Array<string>} r - An array of key codes generated with the reverseButtons option, used to reverse the action for a specific key combination.
19
- * @property {string} textTrigger - Whether the event was triggered by a text input (e.g., mention like @ab).
19
+ * @property {string} textTrigger - Whether the event was triggered by a text input (e.g., autocomplete like @ab).
20
20
  */
21
21
 
22
22
  /**
@@ -579,6 +579,7 @@ export const DEFAULTS = {
579
579
  * @property {import('../../plugins/dropdown/align.js').AlignPluginOptions} [align]
580
580
  * @property {import('../../plugins/modal/audio.js').AudioPluginOptions} [audio]
581
581
  * @property {import('../../plugins/browser/audioGallery.js').AudioGalleryPluginOptions} [audioGallery]
582
+ * @property {import('../../plugins/field/autocomplete.js').AutocompletePluginOptions} [autocomplete]
582
583
  * @property {import('../../plugins/dropdown/backgroundColor.js').BackgroundColorPluginOptions} [backgroundColor]
583
584
  * @property {import('../../plugins/dropdown/blockStyle.js').BlockStylePluginOptions} [blockStyle]
584
585
  * @property {import('../../plugins/command/codeBlock.js').CodeBlockPluginOptions} [codeBlock]
@@ -598,7 +599,6 @@ export const DEFAULTS = {
598
599
  * @property {import('../../plugins/dropdown/lineHeight.js').LineHeightPluginOptions} [lineHeight]
599
600
  * @property {import('../../plugins/modal/link.js').LinkPluginOptions} [link]
600
601
  * @property {import('../../plugins/modal/math.js').MathPluginOptions} [math]
601
- * @property {import('../../plugins/field/mention.js').MentionPluginOptions} [mention]
602
602
  * @property {import('../../plugins/dropdown/paragraphStyle.js').ParagraphStylePluginOptions} [paragraphStyle]
603
603
  * @property {import('../../plugins/dropdown/table/index.js').TablePluginOptions} [table]
604
604
  * @property {import('../../plugins/dropdown/template.js').TemplatePluginOptions} [template]
@@ -946,7 +946,7 @@ function _initTargetElements(key, options, topDiv, targetOptions) {
946
946
  // [sandbox] prop
947
947
  let sandboxValue = frameAttrs.sandbox;
948
948
  if (sandboxValue) {
949
- const requiredSandbox = ['allow-same-origin'];
949
+ const requiredSandbox = ['allow-same-origin', 'allow-scripts'];
950
950
  const userSandbox = sandboxValue.split(/\s+/);
951
951
  const missingSandbox = requiredSandbox.filter((req) => !userSandbox.includes(req));
952
952
 
@@ -955,7 +955,7 @@ function _initTargetElements(key, options, topDiv, targetOptions) {
955
955
  sandboxValue = userSandbox.concat(missingSandbox).join(' ');
956
956
  }
957
957
  } else {
958
- sandboxValue = 'allow-same-origin';
958
+ sandboxValue = 'allow-same-origin allow-scripts';
959
959
  }
960
960
 
961
961
  // iframe [sandbox] attr
@@ -6,6 +6,7 @@ import Numbers from './numbers';
6
6
  import KeyCodeMap from './keyCodeMap';
7
7
  import Clipboard from './clipboard';
8
8
  import Markdown from './markdown';
9
+ import MSOffice from './msOffice';
9
10
 
10
11
  export const env = Env;
11
12
  export const unicode = Unicode;
@@ -15,6 +16,7 @@ export const numbers = Numbers;
15
16
  export const keyCodeMap = KeyCodeMap;
16
17
  export const clipboard = Clipboard;
17
18
  export const markdown = Markdown;
19
+ export const msOffice = MSOffice;
18
20
 
19
21
  export default {
20
22
  env,
@@ -25,4 +27,5 @@ export default {
25
27
  keyCodeMap,
26
28
  clipboard,
27
29
  markdown,
30
+ msOffice,
28
31
  };