suneditor 3.3.0 → 3.3.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.
@@ -20,6 +20,9 @@ class EventManager {
20
20
  /** @type {Array<*>} */
21
21
  #events = [];
22
22
 
23
+ /** @type {?Array<SunEditor.Event.GlobalInfo>} */
24
+ #globalEvents = [];
25
+
23
26
  /**
24
27
  * @constructor
25
28
  * @param {import('./contextProvider').default} contextProvider
@@ -165,11 +168,16 @@ class EventManager {
165
168
  this.#frameContext.get('_ww').addEventListener(type, listener, useCapture);
166
169
  }
167
170
  _w.addEventListener(type, listener, useCapture);
168
- return {
171
+
172
+ const info = {
169
173
  type,
170
174
  listener,
171
175
  useCapture,
172
176
  };
177
+
178
+ this.#globalEvents?.push(info);
179
+
180
+ return info;
173
181
  }
174
182
 
175
183
  /**
@@ -193,6 +201,12 @@ class EventManager {
193
201
  }
194
202
  _w.removeEventListener(type, listener, useCapture);
195
203
 
204
+ const i =
205
+ this.#globalEvents?.findIndex(
206
+ (e) => e.type === type && e.listener === listener && e.useCapture === useCapture,
207
+ ) ?? -1;
208
+ if (i > -1) this.#globalEvents.splice(i, 1);
209
+
196
210
  return null;
197
211
  }
198
212
 
@@ -218,6 +232,15 @@ class EventManager {
218
232
  this.#events = null;
219
233
 
220
234
  this.#geckoActiveEvent &&= this.removeGlobalEvent(this.#geckoActiveEvent);
235
+
236
+ const frameWindow = this.#frameOptions.get('iframe') ? this.#frameContext.get('_ww') : null;
237
+ for (let i = 0, len = this.#globalEvents.length, e; i < len; i++) {
238
+ e = this.#globalEvents[i];
239
+ frameWindow?.removeEventListener(e.type, e.listener, e.useCapture);
240
+ _w.removeEventListener(e.type, e.listener, e.useCapture);
241
+ }
242
+
243
+ this.#globalEvents = null;
221
244
  }
222
245
  }
223
246
 
@@ -290,7 +290,7 @@ class Format {
290
290
  const lineAttrReset = this.#options.get('lineAttrReset');
291
291
  let newEl;
292
292
 
293
- if (/^H[1-6]$|^HR$/i.test(tag)) {
293
+ if (/^H[1-6]$/i.test(tag) || this.#$.component.is(element)) {
294
294
  newEl = dom.utils.createElement(this.#options.get('defaultLine'), null, '<br>');
295
295
  } else if (this.isBrLine(element)) {
296
296
  newEl = dom.utils.createElement(this.#options.get('defaultLine'), null, '<br>');
@@ -33,6 +33,7 @@ class HTML {
33
33
  #disallowedTagsRegExp;
34
34
  #disallowedTagNameRegExp;
35
35
  #allowedTagNameRegExp;
36
+ #emptyLineRegExp;
36
37
 
37
38
  /** @type {Object<string, RegExp>} */
38
39
  #attributeWhitelist;
@@ -97,6 +98,12 @@ class HTML {
97
98
  this.#disallowedTagNameRegExp = new RegExp(`^(${disallowedExtraTags})$`, 'i');
98
99
  this.#allowedTagNameRegExp = new RegExp(`^(${allowedExtraTags})$`, 'i');
99
100
 
101
+ // empty default line probe
102
+ this.#emptyLineRegExp = new RegExp(
103
+ `<${options.get('defaultLine')}(?:\\s[^>]*)?></${options.get('defaultLine')}>`,
104
+ 'i',
105
+ );
106
+
100
107
  // set disallow text nodes
101
108
  const disallowStyleNodes = Object.keys(options.get('_defaultStyleTagMap'));
102
109
  const allowStyleNodes = !options.get('elementWhitelist')
@@ -374,6 +381,8 @@ class HTML {
374
381
  });
375
382
  }
376
383
 
384
+ if (formatFilter && !_freeCodeViewMode) cleanData = this.#dropEmptyLines(cleanData);
385
+
377
386
  return cleanData;
378
387
  }
379
388
 
@@ -1699,6 +1708,28 @@ class HTML {
1699
1708
  return '';
1700
1709
  }
1701
1710
 
1711
+ /**
1712
+ * @description Drops empty default lines (`<p></p>`) from a cleaned HTML string.
1713
+ * Wrapping block-level content in a default line is invalid HTML, so the parser tears the line
1714
+ * apart and leaves caret-less debris behind. An intentionally blank line always carries `<br>`.
1715
+ * @param {string} html Cleaned HTML string
1716
+ * @returns {string} The string without empty default lines
1717
+ */
1718
+ #dropEmptyLines(html) {
1719
+ if (!this.#emptyLineRegExp.test(html)) return html;
1720
+
1721
+ const holder = dom.utils.createElement('DIV', null, html);
1722
+ const lines = holder.querySelectorAll(this.#options.get('defaultLine'));
1723
+ let removed = false;
1724
+ for (let i = lines.length - 1; i >= 0; i--) {
1725
+ if (lines[i].firstChild) continue;
1726
+ dom.utils.removeItem(lines[i]);
1727
+ removed = true;
1728
+ }
1729
+
1730
+ return removed ? holder.innerHTML : html;
1731
+ }
1732
+
1702
1733
  /**
1703
1734
  * @description Checks whether a node is a block-level container in which whitespace-only text
1704
1735
  * children are insignificant formatting whitespace (safe to drop), as opposed to an inline/line
@@ -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
@@ -327,9 +327,25 @@ export const DEFAULTS = {
327
327
  * menu: [
328
328
  * 'p', 'heading', 'blockStyle',
329
329
  * { title: 'Duplicate', icon: 'copy', action: ($, { block }) => block.after(block.cloneNode(true)) },
330
+ * // `'table'` as a string opens the size picker; a custom item inserts a default table directly
331
+ * { title: 'Table', icon: 'table', action: ($) => $.plugins.table.insert(3, 3) },
330
332
  * ],
331
333
  * }
332
334
  * ```
335
+ * @property {string} [blockHandle.maxHeight=""] - Max height of the menu list. Any CSS length; the list scrolls past it.
336
+ * - Unset by default: the menu grows with its items and is only clamped when it would overflow the viewport.
337
+ * @property {string} [blockHandle.minWidth="200px"] - Min width of the menu.
338
+ * @property {function(SunEditor.Deps, { block: HTMLElement, openMenu: function(): void }): void} [blockHandle.onPlusClick] - Runs after the plus button inserted a new line.
339
+ * - Adding the line is fixed behavior; this hook decides what happens next. Nothing does by default.
340
+ * - `block` is the new line, already focused. `openMenu()` opens the block handle's own `menu`.
341
+ * ```js
342
+ * blockHandle: {
343
+ * // open the block handle menu
344
+ * onPlusClick: ($, { openMenu }) => openMenu(),
345
+ * // ...or the slash command menu
346
+ * onPlusClick: ($, { block }) => $.plugins.slashCommand.open(block),
347
+ * }
348
+ * ```
333
349
  * @property {string} [type=""] - Editor type. Use `"document"` for a document-style layout, with optional sub-types after `:`.
334
350
  * ```js
335
351
  * // type
@@ -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'
@@ -1,7 +1,7 @@
1
1
  import Controller from './Controller';
2
2
  import SelectMenu from '../ui/SelectMenu';
3
3
  import { _DragHandle } from '../ui/_DragHandle';
4
- import { dom, numbers, env, converter, keyCodeMap } from '../../helper';
4
+ import { dom, numbers, env, converter, keyCodeMap, unicode } from '../../helper';
5
5
 
6
6
  const { _w, ON_OVER_COMPONENT } = env;
7
7
  const DIRECTION_CURSOR_MAP = {
@@ -975,10 +975,13 @@ class Figure {
975
975
  retainFigureFormat(container, originEl, anchorCover, fileManagerInst) {
976
976
  const isInline = this.#$.component.isInline(container);
977
977
  const originParent = originEl.parentNode;
978
+ const isBareWrapper =
979
+ originParent.children?.length === 1 &&
980
+ !originParent.textContent.replace(unicode.zeroWidthRegExp, '').trim();
978
981
  let existElement =
979
982
  this.#$.format.isBlock(originParent) || dom.check.isWysiwygFrame(originParent) || originParent.nodeType >= 9
980
983
  ? originEl
981
- : Figure.GetContainer(originEl)?.container || originParent || originEl;
984
+ : Figure.GetContainer(originEl)?.container || (isBareWrapper ? originParent : originEl);
982
985
 
983
986
  if (dom.query.getParentElement(originEl, dom.check.isExcludeFormat)) {
984
987
  existElement = anchorCover && anchorCover !== originEl ? anchorCover : originEl;
@@ -69,6 +69,10 @@ export function buildRowHTML(label, iconHTML) {
69
69
  * @property {Object} selectMenuParams - Base SelectMenu params (`position`, `minWidth`, `keydownTarget`, etc.).
70
70
  * @property {function(CommandMenuItem, { icons: Object }): string} [renderCustomItem] - Optional renderer
71
71
  * applied to custom (object) items only. Plugin-string items always render with `buildRowHTML`.
72
+ * @property {function(): void} [prepareCommit] - Optional owner hook run once,
73
+ * - immediately before the user commits inside a dropdown-free flyout (SlashCommand uses it to delete the typed `/query`).
74
+ * - A native submenu gets this for free — its commit routes back through `SelectMenu`'s select callback —
75
+ * - but a flyout is the plugin's own DOM, so the moment has to be intercepted.
72
76
  */
73
77
 
74
78
  /**
@@ -97,8 +101,9 @@ class CommandMenu {
97
101
 
98
102
  /** @type {Map<number, { name: string, plugin: any, li: HTMLElement }>} */
99
103
  #freeMap = new Map();
100
- /** @type {?{ dropdown: HTMLElement, plugin: any, originalParent: ?Node, anchorLi: HTMLElement, unsub: () => void }} */
104
+ /** @type {?{ dropdown: HTMLElement, plugin: any, originalParent: ?Node, anchorLi: HTMLElement, unsub: () => void, offCommit: () => void }} */
101
105
  #flyoutState = null;
106
+ #prepareCommit = null;
102
107
 
103
108
  /**
104
109
  * @type {Array<{ name: string, idx: number }>}
@@ -124,6 +129,7 @@ class CommandMenu {
124
129
  this.#resolveButton = params.resolveButton;
125
130
  this.#rawItems = Array.isArray(params.items) ? params.items : [];
126
131
  this.#renderCustomItem = typeof params.renderCustomItem === 'function' ? params.renderCustomItem : null;
132
+ this.#prepareCommit = typeof params.prepareCommit === 'function' ? params.prepareCommit : null;
127
133
 
128
134
  // Wrap the host's closeMethod so the flyout is always torn down with the menu.
129
135
  const userClose = params.selectMenuParams?.closeMethod;
@@ -134,6 +140,7 @@ class CommandMenu {
134
140
  this.#unregisterAll();
135
141
  userClose?.();
136
142
  },
143
+ subCheckMethod: (index) => this.#freeMap.has(index),
137
144
  subEscMethod: () => {
138
145
  if (!this.#flyoutState) return false;
139
146
  const anchorLi = this.#flyoutState.anchorLi;
@@ -156,11 +163,12 @@ class CommandMenu {
156
163
  this.selectMenu.on(referElement, /** @type {*} */ (onSelect), attr);
157
164
 
158
165
  this.#$.eventManager.addEvent(this.selectMenu.form, 'mousedown', (e) => {
159
- if (env.isMobile) {
166
+ if (env.isMobile || dom.check.isInputElement(dom.query.getEventTarget(e))) {
160
167
  this.#$.store.set('_preventBlur', true);
161
- } else {
162
- e.preventDefault();
168
+ return;
163
169
  }
170
+
171
+ e.preventDefault();
164
172
  });
165
173
 
166
174
  this.#$.eventManager.addEvent(this.selectMenu.form, 'mousemove', this.#onMenuMouseMove.bind(this));
@@ -499,13 +507,46 @@ class CommandMenu {
499
507
  dom.utils.addClass(anchorLi, 'se-submenu-open');
500
508
  plugin.on?.(anchorLi);
501
509
 
510
+ const offCommit = this.#bindFlyoutCommit(dropdown);
511
+
502
512
  // dropdown-off event and unsubscribe on close (see `#closeFlyout`) rather than patching core.
503
513
  const unsub = this.#$.menu.subscribeDropdownOff(() => {
504
514
  this.#closeFlyout();
505
515
  this.selectMenu.close();
506
516
  });
507
517
 
508
- this.#flyoutState = { dropdown, plugin, originalParent, anchorLi, unsub };
518
+ this.#flyoutState = { dropdown, plugin, originalParent, anchorLi, unsub, offCommit };
519
+ }
520
+
521
+ /**
522
+ * @description Run the owner's `prepareCommit` on the first commit gesture inside `dropdown`.
523
+ * - Text inputs are skipped: `prepareCommit` moves the caret back into the wysiwyg, which would pull
524
+ * focus out of a field the user is still typing in (e.g. the color picker's hex box). Those commit
525
+ * through their own submit, and the hook runs on that instead.
526
+ * @param {HTMLElement} dropdown
527
+ * @returns {() => void} Unbind function
528
+ */
529
+ #bindFlyoutCommit(dropdown) {
530
+ if (!this.#prepareCommit) return () => {};
531
+
532
+ let done = false;
533
+ const onCommit = (e) => {
534
+ if (done) return;
535
+ const target = /** @type {HTMLElement} */ (dom.query.getEventTarget(e));
536
+ if (dom.check.isInputElement(target)) return;
537
+ done = true;
538
+ this.#prepareCommit();
539
+ };
540
+
541
+ dropdown.addEventListener('mousedown', onCommit, true);
542
+ dropdown.addEventListener('keydown', onCommit, true);
543
+ dropdown.addEventListener('submit', onCommit, true);
544
+
545
+ return () => {
546
+ dropdown.removeEventListener('mousedown', onCommit, true);
547
+ dropdown.removeEventListener('keydown', onCommit, true);
548
+ dropdown.removeEventListener('submit', onCommit, true);
549
+ };
509
550
  }
510
551
 
511
552
  /**
@@ -518,6 +559,7 @@ class CommandMenu {
518
559
  this.#flyoutState = null;
519
560
 
520
561
  s.unsub?.();
562
+ s.offCommit?.();
521
563
  s.dropdown.style.cssText = '';
522
564
  s.dropdown.style.display = 'none';
523
565