suneditor 3.3.0 → 3.3.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.
Files changed (44) hide show
  1. package/README.md +18 -1
  2. package/dist/suneditor.min.css +2 -2
  3. package/dist/suneditor.min.js +1 -1
  4. package/package.json +3 -2
  5. package/src/assets/suneditor.css +5117 -5105
  6. package/src/core/config/eventManager.js +24 -1
  7. package/src/core/editor.js +6 -1
  8. package/src/core/event/effects/keydown.registry.js +14 -9
  9. package/src/core/event/effects/ruleHelpers.js +119 -1
  10. package/src/core/event/eventOrchestrator.js +15 -6
  11. package/src/core/event/rules/keydown.rule.backspace.js +10 -47
  12. package/src/core/event/rules/keydown.rule.delete.js +28 -66
  13. package/src/core/logic/dom/format.js +2 -2
  14. package/src/core/logic/dom/html.js +85 -5
  15. package/src/core/logic/dom/nodeTransform.js +5 -3
  16. package/src/core/logic/panel/blockHandle.js +61 -24
  17. package/src/core/logic/panel/blockResolver.js +41 -4
  18. package/src/core/logic/shell/pluginManager.js +15 -2
  19. package/src/core/logic/shell/ui.js +38 -28
  20. package/src/core/schema/options.js +20 -3
  21. package/src/core/section/constructor.js +12 -8
  22. package/src/events.js +1 -1
  23. package/src/helper/dom/domQuery.js +10 -4
  24. package/src/helper/googleDocs.js +40 -0
  25. package/src/helper/index.js +3 -0
  26. package/src/modules/contract/Figure.js +5 -2
  27. package/src/modules/ui/CommandMenu.js +47 -5
  28. package/src/modules/ui/SelectMenu.js +97 -36
  29. package/src/plugins/dropdown/table/index.js +25 -8
  30. package/src/plugins/dropdown/table/shared/table.constants.js +2 -0
  31. package/src/plugins/field/slashCommand.js +59 -12
  32. package/types/core/event/effects/ruleHelpers.d.ts +56 -0
  33. package/types/core/logic/panel/blockHandle.d.ts +3 -16
  34. package/types/core/logic/shell/ui.d.ts +14 -5
  35. package/types/core/schema/options.d.ts +31 -5
  36. package/types/events.d.ts +2 -2
  37. package/types/helper/dom/domQuery.d.ts +4 -2
  38. package/types/helper/googleDocs.d.ts +19 -0
  39. package/types/helper/index.d.ts +5 -0
  40. package/types/modules/ui/CommandMenu.d.ts +11 -0
  41. package/types/modules/ui/SelectMenu.d.ts +13 -7
  42. package/types/plugins/dropdown/table/index.d.ts +11 -0
  43. package/types/plugins/dropdown/table/shared/table.constants.d.ts +1 -0
  44. package/types/plugins/field/slashCommand.d.ts +43 -2
@@ -9,6 +9,7 @@ const ZWS_RUN_REGEXP = new RegExp(unicode.zeroWidthSpace + '+', 'g');
9
9
  * @description All HTML related classes involved in the editing area
10
10
  */
11
11
  class HTML {
12
+ /** @type {SunEditor.Deps} */
12
13
  #$;
13
14
  #store;
14
15
 
@@ -33,6 +34,7 @@ class HTML {
33
34
  #disallowedTagsRegExp;
34
35
  #disallowedTagNameRegExp;
35
36
  #allowedTagNameRegExp;
37
+ #emptyLineRegExp;
36
38
 
37
39
  /** @type {Object<string, RegExp>} */
38
40
  #attributeWhitelist;
@@ -73,6 +75,18 @@ class HTML {
73
75
  splitTagStyles[n] += tagStyles[k];
74
76
  }
75
77
  }
78
+
79
+ // A tag with an explicit entry that is also a line/text-style tag inherits the category
80
+ // styles ('@line'/'@text'), so category edits stay effective for it. (e.g. "li")
81
+ const formatLineReg = options.get('formatLine').reg;
82
+ for (const k in splitTagStyles) {
83
+ if (k.startsWith('@')) continue;
84
+ const category = formatLineReg.test(k) ? '@line' : this.#textStyleTags.includes(k) ? '@text' : '';
85
+ if (category && tagStyles[category]) {
86
+ splitTagStyles[k] += (splitTagStyles[k] ? '|' : '') + tagStyles[category];
87
+ }
88
+ }
89
+
76
90
  for (const k in splitTagStyles) {
77
91
  splitTagStyles[k] = new RegExp(`\\s*[^-a-zA-Z](${splitTagStyles[k]})\\s*:[^;]+(?!;)*`, 'gi');
78
92
  }
@@ -97,6 +111,12 @@ class HTML {
97
111
  this.#disallowedTagNameRegExp = new RegExp(`^(${disallowedExtraTags})$`, 'i');
98
112
  this.#allowedTagNameRegExp = new RegExp(`^(${allowedExtraTags})$`, 'i');
99
113
 
114
+ // empty default line probe
115
+ this.#emptyLineRegExp = new RegExp(
116
+ `<${options.get('defaultLine')}(?:\\s[^>]*)?></${options.get('defaultLine')}>`,
117
+ 'i',
118
+ );
119
+
100
120
  // set disallow text nodes
101
121
  const disallowStyleNodes = Object.keys(options.get('_defaultStyleTagMap'));
102
122
  const allowStyleNodes = !options.get('elementWhitelist')
@@ -374,6 +394,8 @@ class HTML {
374
394
  });
375
395
  }
376
396
 
397
+ if (formatFilter && !_freeCodeViewMode) cleanData = this.#dropEmptyLines(cleanData);
398
+
377
399
  return cleanData;
378
400
  }
379
401
 
@@ -1652,7 +1674,7 @@ class HTML {
1652
1674
  ) || [];
1653
1675
  for (let i = ch.length - 1, c; i >= 0; i--) {
1654
1676
  c = /** @type {HTMLElement} */ (ch[i]);
1655
- c.outerHTML = c.innerHTML;
1677
+ c.replaceWith(...c.childNodes);
1656
1678
  }
1657
1679
 
1658
1680
  if (
@@ -1699,6 +1721,28 @@ class HTML {
1699
1721
  return '';
1700
1722
  }
1701
1723
 
1724
+ /**
1725
+ * @description Drops empty default lines (`<p></p>`) from a cleaned HTML string.
1726
+ * Wrapping block-level content in a default line is invalid HTML, so the parser tears the line
1727
+ * apart and leaves caret-less debris behind. An intentionally blank line always carries `<br>`.
1728
+ * @param {string} html Cleaned HTML string
1729
+ * @returns {string} The string without empty default lines
1730
+ */
1731
+ #dropEmptyLines(html) {
1732
+ if (!this.#emptyLineRegExp.test(html)) return html;
1733
+
1734
+ const holder = dom.utils.createElement('DIV', null, html);
1735
+ const lines = holder.querySelectorAll(this.#options.get('defaultLine'));
1736
+ let removed = false;
1737
+ for (let i = lines.length - 1; i >= 0; i--) {
1738
+ if (lines[i].firstChild) continue;
1739
+ dom.utils.removeItem(lines[i]);
1740
+ removed = true;
1741
+ }
1742
+
1743
+ return removed ? holder.innerHTML : html;
1744
+ }
1745
+
1702
1746
  /**
1703
1747
  * @description Checks whether a node is a block-level container in which whitespace-only text
1704
1748
  * children are insignificant formatting whitespace (safe to drop), as opposed to an inline/line
@@ -1861,7 +1905,20 @@ class HTML {
1861
1905
  }
1862
1906
  checkTags.push(t);
1863
1907
  } else {
1864
- p.parentNode.insertBefore(t, p);
1908
+ const ref = p.nextSibling;
1909
+
1910
+ let rest = null;
1911
+ if (t.nextSibling) {
1912
+ rest = p.cloneNode(false);
1913
+ while (t.nextSibling) rest.appendChild(t.nextSibling);
1914
+ }
1915
+
1916
+ p.parentNode.insertBefore(t, ref);
1917
+
1918
+ if (rest) {
1919
+ p.parentNode.insertBefore(rest, ref);
1920
+ checkTags.push(rest);
1921
+ }
1865
1922
  checkTags.push(p);
1866
1923
  }
1867
1924
  }
@@ -1898,19 +1955,42 @@ class HTML {
1898
1955
  }
1899
1956
  }
1900
1957
 
1958
+ // wrap top-level `li` elements without a list parent in a `ul`
1959
+ if (formatFilter) {
1960
+ const orphanCells = dom.query.getListChildNodes(
1961
+ documentFragment,
1962
+ (current) => dom.check.isListCell(current) && !dom.check.isList(current.parentNode),
1963
+ null,
1964
+ );
1965
+
1966
+ for (let i = 0, len = orphanCells.length, t, ul, n, next; i < len; i++) {
1967
+ t = orphanCells[i];
1968
+ if (dom.check.isList(t.parentNode)) continue;
1969
+
1970
+ ul = dom.utils.createElement('UL');
1971
+ t.parentNode.insertBefore(ul, t);
1972
+ n = t;
1973
+ while (n && (dom.check.isListCell(n) || (n.nodeType === 3 && !n.textContent.trim()))) {
1974
+ next = n.nextSibling;
1975
+ ul.appendChild(n);
1976
+ n = next;
1977
+ }
1978
+ }
1979
+ }
1980
+
1901
1981
  for (let i = 0, len = withoutFormatCells.length, t, f; i < len; i++) {
1902
1982
  t = withoutFormatCells[i];
1903
1983
 
1904
1984
  f = dom.utils.createElement('DIV');
1905
- f.innerHTML = t.innerHTML;
1985
+ while (t.firstChild) f.appendChild(t.firstChild);
1906
1986
 
1907
- if (t.textContent.trim().length === 0 && this.#isAllTextStyleNodes(t)) {
1987
+ if (f.textContent.trim().length === 0 && this.#isAllTextStyleNodes(f)) {
1908
1988
  let leaf = /** @type {Element} */ (f);
1909
1989
  while (leaf.firstElementChild) leaf = leaf.firstElementChild;
1910
1990
  leaf.innerHTML = '<br>';
1911
1991
  }
1912
1992
 
1913
- t.innerHTML = f.outerHTML;
1993
+ t.appendChild(f);
1914
1994
  }
1915
1995
  }
1916
1996
 
@@ -106,8 +106,9 @@ class NodeTransform {
106
106
 
107
107
  if (temp) {
108
108
  if (dom.check.isListCell(newEl) && dom.check.isList(temp) && temp.firstElementChild) {
109
- newEl.innerHTML = temp.firstElementChild.innerHTML;
110
- dom.utils.removeItem(temp.firstElementChild);
109
+ const firstCell = temp.firstElementChild;
110
+ while (firstCell.firstChild) newEl.appendChild(firstCell.firstChild);
111
+ dom.utils.removeItem(firstCell);
111
112
  if (temp.children.length > 0) newEl.appendChild(temp);
112
113
  } else {
113
114
  newEl.appendChild(temp);
@@ -300,7 +301,8 @@ class NodeTransform {
300
301
  }
301
302
  }
302
303
  } else {
303
- child.innerHTML += next.innerHTML;
304
+ while (next.firstChild) child.appendChild(next.firstChild);
305
+ child.normalize();
304
306
  }
305
307
 
306
308
  dom.utils.removeItem(next);
@@ -37,6 +37,10 @@ class BlockHandle {
37
37
  #plusBtn;
38
38
  #dragBtn;
39
39
  #menuConfig;
40
+ #menuMaxHeight;
41
+ #menuMinWidth;
42
+ /** @type {?function(SunEditor.Deps, { block: HTMLElement, openMenu: function(): void }): void} */
43
+ #onPlusClickHook;
40
44
 
41
45
  /** @type {CommandMenu|null} */
42
46
  #actionMenu = null;
@@ -74,17 +78,21 @@ class BlockHandle {
74
78
  * @param {HTMLElement} blockHandle - Handle group (.se-block-handle)
75
79
  * @param {HTMLElement} blockHandlePlus - Plus button
76
80
  * @param {HTMLElement} blockHandleDrag - Drag button
77
- * @param {Array<string | { title: string, icon?: string, action: function(SunEditor.Deps, { block: HTMLElement }): void }>|null} menuConfig
78
- * Menu entries. Strings resolve via `ResolveButton` (plugin names, built-in commands). Objects
79
- * define a custom row whose `action` is invoked with the Deps bag and the current block element.
81
+ * @param {Object|Array<*>|null} blockHandleOptions - The `blockHandle` option object (`{ menu, onPlusClick, maxHeight, minWidth }`).
82
+ * - An array is accepted as a shorthand for `{ menu: [...] }`.
80
83
  */
81
- constructor($, blockHandleArea, blockHandle, blockHandlePlus, blockHandleDrag, menuConfig) {
84
+ constructor($, blockHandleArea, blockHandle, blockHandlePlus, blockHandleDrag, blockHandleOptions) {
82
85
  this.#$ = $;
83
86
  this.#area = blockHandleArea;
84
87
  this.#handle = blockHandle;
85
88
  this.#plusBtn = blockHandlePlus;
86
89
  this.#dragBtn = blockHandleDrag;
87
- this.#menuConfig = menuConfig || null;
90
+
91
+ const opts = Array.isArray(blockHandleOptions) ? { menu: blockHandleOptions } : blockHandleOptions || {};
92
+ this.#menuConfig = opts.menu || null;
93
+ this.#menuMaxHeight = typeof opts.maxHeight === 'string' ? opts.maxHeight : '';
94
+ this.#menuMinWidth = typeof opts.minWidth === 'string' ? opts.minWidth : '200px';
95
+ this.#onPlusClickHook = typeof opts.onPlusClick === 'function' ? opts.onPlusClick : null;
88
96
 
89
97
  this.#$.contextProvider.carrierWrapper.appendChild(this.#handle);
90
98
 
@@ -579,6 +587,7 @@ class BlockHandle {
579
587
  /**
580
588
  * @description Plus button click — insert new line after current block.
581
589
  * Mirrors Enter-at-end-of-line behavior from keydown.rule.enter.
590
+ * Adding the line is the fixed behavior; `onPlusClick` decides what happens next (nothing by default).
582
591
  * @param {MouseEvent} e
583
592
  */
584
593
  #onPlusClick(e) {
@@ -588,10 +597,20 @@ class BlockHandle {
588
597
  if (!this.#currentBlock) return;
589
598
 
590
599
  const newLine = this.#$.format.addLineAfter(this.#currentBlock);
591
- if (newLine) {
592
- this.#$.selection.setRange(newLine, 1, newLine, 1);
593
- this.#$.history.push(false);
594
- }
600
+ if (!newLine) return;
601
+
602
+ this.#$.selection.setRange(newLine, 1, newLine, 1);
603
+ this.#$.history.push(false);
604
+
605
+ if (!this.#onPlusClickHook) return;
606
+
607
+ this.#setCurrentBlock(newLine);
608
+ this.#updatePosition(newLine);
609
+
610
+ this.#onPlusClickHook(this.#$, {
611
+ block: newLine,
612
+ openMenu: () => this.#toggleActionMenu(),
613
+ });
595
614
  }
596
615
 
597
616
  /**
@@ -777,6 +796,22 @@ class BlockHandle {
777
796
  // Skip if this click was actually a drag
778
797
  if (this.#isDragging) return;
779
798
 
799
+ const componentInfo = this.#$.component.get(this.#currentBlock);
800
+ if (componentInfo) {
801
+ this.#actionMenu?.close();
802
+ this.#clearHoverLines();
803
+ this.#$.component.select(componentInfo.target, componentInfo.pluginName);
804
+ return;
805
+ }
806
+
807
+ this.#toggleActionMenu();
808
+ }
809
+
810
+ /**
811
+ * @description Open the block action menu (or close it when already open). Shared by the drag button
812
+ * and by the `openMenu` helper handed to the `onPlusClick` hook.
813
+ */
814
+ #toggleActionMenu() {
780
815
  if (!this.#menuConfig) return;
781
816
 
782
817
  // Lazy build — plugins are not yet instantiated when BlockHandle is constructed
@@ -786,23 +821,24 @@ class BlockHandle {
786
821
 
787
822
  if (this.#actionMenu.isOpen) {
788
823
  this.#actionMenu.close();
789
- } else {
790
- this.#expandRangeToFullLines();
824
+ return;
825
+ }
791
826
 
792
- // Highlight selected range lines
793
- const lines = this.#$.format.getLines(null);
794
- if (lines.length > 0) {
795
- this.#setHoverLines(lines);
796
- }
827
+ this.#expandRangeToFullLines();
797
828
 
798
- // Choose open direction based on available space.
799
- const btnGlobal = this.#$.offset.getGlobal(this.#dragBtn);
800
- const spaceBelow = dom.utils.getClientSize().h - (btnGlobal.top - _w.scrollY + btnGlobal.height);
801
- const spaceAbove = btnGlobal.top - _w.scrollY;
802
- const horiz = this.#$.options.get('_rtl') ? 'left' : 'right';
803
- const dir = `${horiz}-${spaceBelow >= spaceAbove ? 'bottom' : 'top'}`;
804
- this.#actionMenu.open(dir);
829
+ // Highlight selected range lines
830
+ const lines = this.#$.format.getLines(null);
831
+ if (lines.length > 0) {
832
+ this.#setHoverLines(lines);
805
833
  }
834
+
835
+ // Choose open direction based on available space.
836
+ const btnGlobal = this.#$.offset.getGlobal(this.#dragBtn);
837
+ const spaceBelow = dom.utils.getClientSize().h - (btnGlobal.top - _w.scrollY + btnGlobal.height);
838
+ const spaceAbove = btnGlobal.top - _w.scrollY;
839
+ const horiz = this.#$.options.get('_rtl') ? 'left' : 'right';
840
+ const dir = `${horiz}-${spaceBelow >= spaceAbove ? 'bottom' : 'top'}`;
841
+ this.#actionMenu.open(dir);
806
842
  }
807
843
 
808
844
  /**
@@ -816,7 +852,8 @@ class BlockHandle {
816
852
  selectMenuParams: {
817
853
  position: 'right-top',
818
854
  dir: this.#$.options.get('_rtl') ? 'rtl' : 'ltr',
819
- minWidth: '200px',
855
+ minWidth: this.#menuMinWidth,
856
+ maxHeight: this.#menuMaxHeight,
820
857
  keydownTarget: _w,
821
858
  closeMethod: () => {
822
859
  dom.utils.removeClass(this.#dragBtn, 'on');
@@ -35,6 +35,7 @@ const TABLE_INNER_RE = /^(THEAD|TBODY|TR|TD|TH)$/;
35
35
  */
36
36
  function classifyType(el) {
37
37
  const tag = el.nodeName;
38
+ if (isComponentContainer(el)) return 'component';
38
39
  if (tag === 'P' || tag === 'DIV') return 'p';
39
40
  if (HEADING_RE.test(tag)) return 'heading';
40
41
  if (tag === 'LI') return 'list-item';
@@ -74,6 +75,24 @@ function isInsideComponent(node) {
74
75
  return false;
75
76
  }
76
77
 
78
+ /**
79
+ * @description Resolve a node to its outermost component container, but only when that container sits directly on the wysiwyg root.
80
+ * @param {Node} node
81
+ * @returns {HTMLElement|null} The top-level component container, or `null` if there is none
82
+ */
83
+ function resolveTopLevelComponent(node) {
84
+ let el = node;
85
+ let outermost = null;
86
+
87
+ while (el && !isWysiwygFrame(el)) {
88
+ if (el.nodeType === 1 && isComponentContainer(/** @type {Element} */ (el)))
89
+ outermost = /** @type {HTMLElement} */ (el);
90
+ el = el.parentNode;
91
+ }
92
+
93
+ return outermost && outermost.parentNode && isWysiwygFrame(outermost.parentNode) ? outermost : null;
94
+ }
95
+
77
96
  /**
78
97
  * @description Count block-level ancestors between element and wysiwyg root.
79
98
  * @param {HTMLElement} element
@@ -193,8 +212,10 @@ export function resolveBlock(node, format, wysiwygFrame, mouseY) {
193
212
  // Already at wysiwyg root
194
213
  if (isWysiwygFrame(node)) return null;
195
214
 
196
- // Skip components (images, videos, etc.) — they have their own interaction
197
- if (isInsideComponent(node)) return null;
215
+ if (isInsideComponent(node)) {
216
+ const component = resolveTopLevelComponent(node);
217
+ return component ? describeBlock(component, format, mouseY) : null;
218
+ }
198
219
 
199
220
  let resolved = null;
200
221
 
@@ -248,8 +269,24 @@ export function resolveBlock(node, format, wysiwygFrame, mouseY) {
248
269
 
249
270
  if (!resolved) return null;
250
271
 
251
- // Final component check on resolved element
252
- if (isInsideComponent(resolved)) return null;
272
+ // Final component check on the resolved element (e.g. getLine walked into a component)
273
+ if (isInsideComponent(resolved)) {
274
+ const component = resolveTopLevelComponent(resolved);
275
+ return component ? describeBlock(component, format, mouseY) : null;
276
+ }
277
+
278
+ return describeBlock(resolved, format, mouseY);
279
+ }
280
+
281
+ /**
282
+ * @description Build the `BlockInfo` for an already-resolved block element.
283
+ * @param {HTMLElement} element - Resolved block-level element
284
+ * @param {FormatAPI} format - Injected format methods
285
+ * @param {number} [mouseY] - Mouse clientY for nested list resolution
286
+ * @returns {BlockInfo}
287
+ */
288
+ function describeBlock(element, format, mouseY) {
289
+ let resolved = element;
253
290
 
254
291
  // For UL/OL, resolve to the closest child LI by mouse Y.
255
292
  // For LI with nested sub-lists, find the deepest child LI.
@@ -151,9 +151,22 @@ class PluginManager {
151
151
  let retainFilter;
152
152
  if ((retainFilter = this.#options.get('__pluginRetainFilter'))) {
153
153
  this.#retainFormatCheckers.forEach((plugin, query) => {
154
- const infoLst = domParser.querySelectorAll(query);
154
+ let infoLst;
155
+ try {
156
+ infoLst = domParser.querySelectorAll(query);
157
+ } catch (error) {
158
+ console.warn(`[SUNEDITOR.retainFormat.fail]-[${plugin.key}]`, error.message);
159
+ return;
160
+ }
161
+
155
162
  for (let i = 0, len = infoLst.length; i < len; i++) {
156
- if (retainFilter === true || retainFilter[plugin.key] !== false) plugin.method(infoLst[i]);
163
+ if (retainFilter !== true && retainFilter[plugin.key] === false) continue;
164
+
165
+ try {
166
+ plugin.method(infoLst[i]);
167
+ } catch (error) {
168
+ console.warn(`[SUNEDITOR.retainFormat.fail]-[${plugin.key}]`, error.message);
169
+ }
157
170
  }
158
171
  });
159
172
  }
@@ -72,6 +72,16 @@ class UIManager {
72
72
  */
73
73
  #blockHandle = null;
74
74
 
75
+ /**
76
+ * @description Currently open `SelectMenu` instances.
77
+ * - There is one editor-wide "a select menu is open" flag but many `SelectMenu` instances, so it
78
+ * cannot be a plain boolean: the last writer would win. A field plugin that closes its own menu on
79
+ * every keystroke (`autocomplete`) used to flip the flag off while another menu (`slashCommand`) was
80
+ * still open, which let the wysiwyg keydown handler run against the open menu.
81
+ * @type {Set<*>}
82
+ */
83
+ #openSelectMenus = new Set();
84
+
75
85
  /**
76
86
  * @constructor
77
87
  * @param {SunEditor.Kernel} kernel
@@ -110,12 +120,6 @@ class UIManager {
110
120
  this.#closeSignal = false;
111
121
  this.#backWrapper = /** @type {HTMLElement} */ (this.#carrierWrapper.querySelector('.se-back-wrapper'));
112
122
 
113
- /**
114
- * @description Whether `SelectMenu` is open
115
- * @type {boolean}
116
- */
117
- this.selectMenuOn = false;
118
-
119
123
  /**
120
124
  * @description Currently open `Controller` info array
121
125
  * @type {Array<SunEditor.Module.Controller.Info>}
@@ -136,6 +140,27 @@ class UIManager {
136
140
  this._figureContainer = null;
137
141
  }
138
142
 
143
+ /**
144
+ * @description Whether any `SelectMenu` is currently open.
145
+ * - Read-only: a menu announces itself through {@link setSelectMenuOpen}. Derived from the set of
146
+ * open instances so an unrelated menu closing cannot clear the flag for a menu that is still open.
147
+ * @returns {boolean}
148
+ */
149
+ get selectMenuOn() {
150
+ return this.#openSelectMenus.size > 0;
151
+ }
152
+
153
+ /**
154
+ * @internal
155
+ * @description `SelectMenu` open-state notification. Called by `SelectMenu.open()` / `.close()`.
156
+ * @param {*} instance The `SelectMenu` instance changing state
157
+ * @param {boolean} open `true` on open, `false` on close
158
+ */
159
+ setSelectMenuOpen(instance, open) {
160
+ if (open) this.#openSelectMenus.add(instance);
161
+ else this.#openSelectMenus.delete(instance);
162
+ }
163
+
139
164
  /**
140
165
  * @description Set editor frame styles.
141
166
  * - Define the style of the edit area
@@ -291,12 +316,6 @@ class UIManager {
291
316
 
292
317
  this.#activeDirBtn(rtl);
293
318
 
294
- // reverse toolbar buttons
295
- this.#reverseToolbarButtons(this.#context.get('toolbar_buttonTray'));
296
- if (this.#context.has('toolbar_sub_buttonTray')) {
297
- this.#reverseToolbarButtons(this.#context.get('toolbar_sub_buttonTray'));
298
- }
299
-
300
319
  if (this.#store.mode.isBalloon) this.#$.toolbar._showBalloon();
301
320
  else if (this.#store.mode.isSubBalloon) this.#$.subToolbar._showBalloon();
302
321
  } catch (e) {
@@ -614,7 +633,7 @@ class UIManager {
614
633
  rt.get('blockHandle'),
615
634
  rt.get('blockHandlePlus'),
616
635
  rt.get('blockHandleDrag'),
617
- blockHandleOpt.menu,
636
+ blockHandleOpt,
618
637
  );
619
638
  }
620
639
  }
@@ -783,20 +802,6 @@ class UIManager {
783
802
  }
784
803
  }
785
804
 
786
- /**
787
- * @description Reverse the order of toolbar button groups (excluding the more-layer).
788
- * @param {HTMLElement} buttonTray - The `.se-btn-tray` element.
789
- */
790
- #reverseToolbarButtons(buttonTray) {
791
- if (!buttonTray) return;
792
- const moreLayer = buttonTray.querySelector('.se-toolbar-more-layer');
793
- const children = Array.from(buttonTray.children).filter((c) => c !== moreLayer);
794
- for (let i = children.length - 1; i >= 0; i--) {
795
- buttonTray.appendChild(children[i]);
796
- }
797
- if (moreLayer) buttonTray.appendChild(moreLayer);
798
- }
799
-
800
805
  /**
801
806
  * @internal
802
807
  * @description Set the disabled button list
@@ -954,7 +959,12 @@ class UIManager {
954
959
  this._updatePlaceholder(fc);
955
960
  // document type page
956
961
  if (fc.has('documentType_use_page')) {
957
- fc.get('documentTypePageMirror').innerHTML = fc.get('wysiwyg').innerHTML;
962
+ const mirror = fc.get('documentTypePageMirror');
963
+ const frag = mirror.ownerDocument.createDocumentFragment();
964
+ for (let n = fc.get('wysiwyg').firstChild; n; n = n.nextSibling) {
965
+ frag.appendChild(n.cloneNode(true));
966
+ }
967
+ mirror.replaceChildren(frag);
958
968
  fc.get('documentType').rePage(true);
959
969
  }
960
970
  }
@@ -71,6 +71,7 @@ export const DEFAULTS = {
71
71
  '@text': 'font-family|font-size|color|background-color|width|height',
72
72
  '@line': 'text-align|margin|margin-left|margin-right|line-height',
73
73
  '@component': 'width|height|min-width',
74
+ li: 'font-family|font-size|color|background-color|font-weight|font-style',
74
75
  'table|th|td':
75
76
  'border|border-[a-z]+|color|background-color|text-align|float|font-weight|text-decoration|font-style|vertical-align',
76
77
  'table|td': 'width',
@@ -327,9 +328,25 @@ export const DEFAULTS = {
327
328
  * menu: [
328
329
  * 'p', 'heading', 'blockStyle',
329
330
  * { title: 'Duplicate', icon: 'copy', action: ($, { block }) => block.after(block.cloneNode(true)) },
331
+ * // `'table'` as a string opens the size picker; a custom item inserts a default table directly
332
+ * { title: 'Table', icon: 'table', action: ($) => $.plugins.table.insert(3, 3) },
330
333
  * ],
331
334
  * }
332
335
  * ```
336
+ * @property {string} [blockHandle.maxHeight=""] - Max height of the menu list. Any CSS length; the list scrolls past it.
337
+ * - Unset by default: the menu grows with its items and is only clamped when it would overflow the viewport.
338
+ * @property {string} [blockHandle.minWidth="200px"] - Min width of the menu.
339
+ * @property {function(SunEditor.Deps, { block: HTMLElement, openMenu: function(): void }): void} [blockHandle.onPlusClick] - Runs after the plus button inserted a new line.
340
+ * - Adding the line is fixed behavior; this hook decides what happens next. Nothing does by default.
341
+ * - `block` is the new line, already focused. `openMenu()` opens the block handle's own `menu`.
342
+ * ```js
343
+ * blockHandle: {
344
+ * // open the block handle menu
345
+ * onPlusClick: ($, { openMenu }) => openMenu(),
346
+ * // ...or the slash command menu
347
+ * onPlusClick: ($, { block }) => $.plugins.slashCommand.open(block),
348
+ * }
349
+ * ```
333
350
  * @property {string} [type=""] - Editor type. Use `"document"` for a document-style layout, with optional sub-types after `:`.
334
351
  * ```js
335
352
  * // type
@@ -441,7 +458,7 @@ export const DEFAULTS = {
441
458
  * - Value is a pipe-delimited list of allowed style names.
442
459
  * - Resolution order when filtering an element: `@component` (for `.se-component` containers) → explicit tag entry → `@line` (for formatLine elements) → `@text` (for textStyleTags).
443
460
  * - `@component` guards the inline sizing (`width`/`height`/`min-width`) the editor writes on a media component's container for percentage-based sizes; keep these so a clean() round-trip does not reset the component to full width.
444
- * - An explicit tag entry **replaces** the category default — include category styles in the value if you want both.
461
+ * - An explicit tag entry is **merged** with its category default when the tag belongs to one (`@line` for formatLine elements, else `@text` for textStyleTags) — the entry adds styles on top of the category's.
445
462
  * - Merged with {@link DEFAULTS.TAG_STYLES}; user-supplied keys win.
446
463
  * ```js
447
464
  * {
@@ -449,8 +466,8 @@ export const DEFAULTS = {
449
466
  * '@text': 'color|font-size|background-color', // default for span, b, i, em, ...
450
467
  * '@line': 'text-align|margin|line-height', // default for p, h1-h6, div, li, ...
451
468
  * 'table|td': 'border|color|background-color', // per-tag whitelist
452
- * div: 'color', // explicit override; ignores `@line` default
453
- * hr: 'border-top',
469
+ * div: 'color', // merged with the `@line` default (div is a line element)
470
+ * hr: 'border-top',
454
471
  * }
455
472
  * }
456
473
  * ```
@@ -44,12 +44,16 @@ function Constructor(editorTargets, options) {
44
44
  if (options.plugins) {
45
45
  const excludedPlugins = options.excludedPlugins || [];
46
46
  const originPlugins = options.plugins;
47
- const pluginsValues = (Array.isArray(originPlugins) ? originPlugins : Object.keys(originPlugins))
48
- .filter((name) => !excludedPlugins.includes(name))
49
- .map((name) => originPlugins[name]);
50
-
51
- for (let i = 0, len = pluginsValues.length, p; i < len; i++) {
52
- p = pluginsValues[i].default || pluginsValues[i];
47
+ const pluginsEntries = Array.isArray(originPlugins)
48
+ ? originPlugins.map((plugin) => [null, plugin])
49
+ : Object.keys(originPlugins).map((name) => [name, originPlugins[name]]);
50
+
51
+ for (let i = 0, len = pluginsEntries.length, name, p; i < len; i++) {
52
+ name = pluginsEntries[i][0];
53
+ p = pluginsEntries[i][1];
54
+ p = p?.default || p;
55
+ if (!p?.key) continue;
56
+ if (excludedPlugins.includes(p.key) || (name !== null && excludedPlugins.includes(name))) continue;
53
57
  plugins[p.key] = p;
54
58
  }
55
59
  }
@@ -921,7 +925,7 @@ export function InitOptions(options, editorTargets, plugins) {
921
925
  'toolbar_sub_width',
922
926
  subbar.width ? (numbers.is(subbar.width) ? subbar.width + 'px' : subbar.width) : 'auto',
923
927
  );
924
- subButtons = o.get('_rtl') ? subbar.buttonList.reverse() : subbar.buttonList;
928
+ subButtons = subbar.buttonList;
925
929
  o.set('buttons_sub', new Set(subButtons.toString().split(',')));
926
930
  }
927
931
  }
@@ -1054,7 +1058,7 @@ export function InitOptions(options, editorTargets, plugins) {
1054
1058
  i: icons,
1055
1059
  l: /** @type {Object<string, string>} */ (options.lang || _defaultLang),
1056
1060
  v: (options.value = typeof options.value === 'string' ? options.value : null),
1057
- buttons: o.get('_rtl') ? buttonList.reverse() : buttonList,
1061
+ buttons: buttonList,
1058
1062
  subButtons: subButtons,
1059
1063
  statusbarContainer:
1060
1064
  typeof options.statusbar_container === 'string'
package/src/events.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * @property {Event} event - event object
18
18
  * @property {string} data - drop data
19
19
  * @property {boolean} maxCharCount - is max char count
20
- * @property {string} from - `"SE"`|`"MS"`|`""` - source
20
+ * @property {string} from - `"SE"`|`"MS"`|`"GOOGLE"`|`""` - source
21
21
  */
22
22
 
23
23
  // --- media