suneditor 3.2.6 → 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.
Files changed (51) hide show
  1. package/README.md +18 -1
  2. package/dist/suneditor-contents.min.css +1 -1
  3. package/dist/suneditor.min.css +3 -3
  4. package/dist/suneditor.min.js +1 -1
  5. package/package.json +4 -4
  6. package/src/assets/design/color.css +5 -2
  7. package/src/assets/design/size.css +6 -2
  8. package/src/assets/icons/defaultIcons.js +195 -162
  9. package/src/assets/suneditor.css +5101 -5105
  10. package/src/core/config/eventManager.js +24 -1
  11. package/src/core/config/optionProvider.js +5 -4
  12. package/src/core/event/actions/index.js +8 -4
  13. package/src/core/event/effects/keydown.registry.js +59 -10
  14. package/src/core/event/ports.js +2 -0
  15. package/src/core/event/rules/keydown.rule.backspace.js +52 -1
  16. package/src/core/event/rules/keydown.rule.delete.js +51 -0
  17. package/src/core/logic/dom/format.js +1 -1
  18. package/src/core/logic/dom/html.js +31 -0
  19. package/src/core/logic/panel/blockHandle.js +61 -24
  20. package/src/core/logic/panel/blockResolver.js +41 -4
  21. package/src/core/logic/panel/finder.js +3 -0
  22. package/src/core/logic/panel/menu.js +38 -1
  23. package/src/core/logic/panel/toolbar.js +1 -0
  24. package/src/core/logic/shell/_commandExecutor.js +2 -1
  25. package/src/core/logic/shell/pluginManager.js +15 -2
  26. package/src/core/logic/shell/ui.js +73 -32
  27. package/src/core/schema/frameContext.js +1 -1
  28. package/src/core/schema/options.js +49 -3
  29. package/src/core/section/constructor.js +17 -9
  30. package/src/modules/contract/Controller.js +14 -0
  31. package/src/modules/contract/Figure.js +5 -2
  32. package/src/modules/ui/CommandMenu.js +53 -14
  33. package/src/modules/ui/SelectMenu.js +103 -36
  34. package/src/plugins/dropdown/table/index.js +25 -8
  35. package/src/plugins/dropdown/table/services/table.cell.js +4 -7
  36. package/src/plugins/dropdown/table/shared/table.constants.js +2 -0
  37. package/src/plugins/field/slashCommand.js +59 -12
  38. package/types/assets/icons/defaultIcons.d.ts +1 -0
  39. package/types/core/event/actions/index.d.ts +1 -0
  40. package/types/core/event/effects/keydown.registry.d.ts +4 -0
  41. package/types/core/event/ports.d.ts +3 -0
  42. package/types/core/logic/panel/blockHandle.d.ts +3 -16
  43. package/types/core/logic/panel/menu.d.ts +8 -0
  44. package/types/core/logic/shell/ui.d.ts +31 -5
  45. package/types/core/schema/frameContext.d.ts +6 -2
  46. package/types/core/schema/options.d.ts +95 -4
  47. package/types/modules/ui/CommandMenu.d.ts +11 -0
  48. package/types/modules/ui/SelectMenu.d.ts +13 -7
  49. package/types/plugins/dropdown/table/index.d.ts +11 -0
  50. package/types/plugins/dropdown/table/shared/table.constants.d.ts +1 -0
  51. package/types/plugins/field/slashCommand.d.ts +43 -2
@@ -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, evClick: ?SunEditor.Event.Info }} */
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,16 +507,46 @@ class CommandMenu {
499
507
  dom.utils.addClass(anchorLi, 'se-submenu-open');
500
508
  plugin.on?.(anchorLi);
501
509
 
502
- // Clicking inside the flyout's dropdown should dismiss both the flyout and the parent menu —
503
- // matches the toolbar behavior where a click commits the selection.
504
- const evClick = this.#$.eventManager.addEvent(dropdown, 'click', () =>
505
- _w.setTimeout(() => {
506
- this.#closeFlyout();
507
- this.selectMenu.close();
508
- }, 0),
509
- );
510
+ const offCommit = this.#bindFlyoutCommit(dropdown);
511
+
512
+ // dropdown-off event and unsubscribe on close (see `#closeFlyout`) rather than patching core.
513
+ const unsub = this.#$.menu.subscribeDropdownOff(() => {
514
+ this.#closeFlyout();
515
+ this.selectMenu.close();
516
+ });
517
+
518
+ this.#flyoutState = { dropdown, plugin, originalParent, anchorLi, unsub, offCommit };
519
+ }
510
520
 
511
- this.#flyoutState = { dropdown, plugin, originalParent, anchorLi, evClick };
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
+ };
512
550
  }
513
551
 
514
552
  /**
@@ -520,7 +558,8 @@ class CommandMenu {
520
558
  if (!s) return;
521
559
  this.#flyoutState = null;
522
560
 
523
- this.#$?.eventManager.removeEvent(s.evClick);
561
+ s.unsub?.();
562
+ s.offCommit?.();
524
563
  s.dropdown.style.cssText = '';
525
564
  s.dropdown.style.display = 'none';
526
565
 
@@ -16,9 +16,10 @@ const MENU_MIN_HEIGHT = 38;
16
16
  * @property {number} [splitNum=0] Optional split number for horizontal positioning; defines how many items per row
17
17
  * @property {() => void} [openMethod] Optional method to call when the menu is opened
18
18
  * @property {() => void} [closeMethod] Optional method to call when the menu is closed
19
- * @property {() => boolean} [subEscMethod] Optional owner hook invoked on ESC before the menu closes.
20
- * Return `true` if it dismissed an owner-managed sub-panel (e.g. CommandMenu's flyout), so ESC only
21
- * closes that sub-panel and keeps the menu open.
19
+ * @property {() => boolean} [subEscMethod] Optional owner hook that dismisses an owner-managed sub-panel (e.g. CommandMenu's dropdown-free flyout) and puts the cursor back on its row.
20
+ * Return `true` when a sub-panel was actually dismissed.
21
+ * @property {(index: number) => boolean} [subCheckMethod] Optional owner hook answering "does the row at `index` own a sub-panel?".
22
+ * - A query only — it must not open anything.
22
23
  * @property {string} [maxHeight] Optional max-height CSS value (e.g. `"200px"`). Enables scrolling when items exceed this height.
23
24
  * @property {string} [minWidth] Optional min-width CSS value (e.g. `"130px"`).
24
25
  * @property {*} [keydownTarget] Optional override for the keyboard navigation target. By default `on()` listens
@@ -41,6 +42,7 @@ class SelectMenu {
41
42
  #eventHandlers;
42
43
  #globalEventHandlers;
43
44
 
45
+ #listInner = null;
44
46
  #refer = null;
45
47
  #keydownTarget = null;
46
48
  #keydownTargetOverride = null;
@@ -86,6 +88,7 @@ class SelectMenu {
86
88
  this.openMethod = params.openMethod;
87
89
  this.closeMethod = params.closeMethod;
88
90
  this.subEscMethod = params.subEscMethod || null;
91
+ this.subCheckMethod = params.subCheckMethod || null;
89
92
  this.maxHeight = params.maxHeight || '';
90
93
  this.minWidth = params.minWidth || '';
91
94
  this.#keydownTargetOverride = params.keydownTarget || null;
@@ -135,7 +138,7 @@ class SelectMenu {
135
138
  * );
136
139
  */
137
140
  create(items, menus) {
138
- this.form.firstElementChild.innerHTML = '';
141
+ this.#listInner.innerHTML = '';
139
142
 
140
143
  // remove existing submenu elements from form
141
144
  for (const [, data] of this.#submenuData) {
@@ -228,6 +231,8 @@ class SelectMenu {
228
231
  '<div class="se-list-inner"' + (innerStyle ? ' style="' + innerStyle + '"' : '') + '></div>',
229
232
  );
230
233
 
234
+ this.#listInner = /** @type {HTMLElement} */ (this.form.firstElementChild);
235
+
231
236
  referElement.parentNode.insertBefore(this.form, referElement);
232
237
  }
233
238
 
@@ -247,7 +252,7 @@ class SelectMenu {
247
252
  * selectMenu.open('', '[data-command="' + this.align + '"]');
248
253
  */
249
254
  open(position, onItemQuerySelector) {
250
- this.#$.ui.selectMenuOn = true;
255
+ this.#$.ui.setSelectMenuOpen(this, true);
251
256
 
252
257
  this.openMethod?.();
253
258
 
@@ -298,10 +303,11 @@ class SelectMenu {
298
303
  * @description Select menu close
299
304
  */
300
305
  close() {
301
- this.#$.ui.selectMenuOn = false;
306
+ this.#$.ui.setSelectMenuOpen(this, false);
302
307
  dom.utils.removeClass(this.#refer, 'on');
303
308
  this.#init();
304
309
  this.form?.removeAttribute('style');
310
+ if (this.#listInner) this.#resetClamp();
305
311
  this.isOpen = false;
306
312
 
307
313
  this.closeMethod?.();
@@ -521,7 +527,7 @@ class SelectMenu {
521
527
  * @param {string} html - The HTML string representing the menu items.
522
528
  */
523
529
  #createFormat(html) {
524
- this.form.firstElementChild.innerHTML += `<ul class="se-list-basic se-list-checked${this.horizontal ? ' se-list-horizontal' : ''}">${html}</ul>`;
530
+ this.#listInner.innerHTML += `<ul class="se-list-basic se-list-checked${this.horizontal ? ' se-list-horizontal' : ''}">${html}</ul>`;
525
531
  }
526
532
 
527
533
  /**
@@ -551,6 +557,44 @@ class SelectMenu {
551
557
  this.#selectItem(selectIndex);
552
558
  }
553
559
 
560
+ /**
561
+ * @description Restores the list box to its `maxHeight` param, dropping any previous viewport clamp.
562
+ * - `#setPosition` may run twice (the `_re` retry), so the clamp must not accumulate.
563
+ */
564
+ #resetClamp() {
565
+ this.form.style.height = '';
566
+ this.#listInner.style.maxHeight = this.maxHeight;
567
+ this.#listInner.style.overflowY = this.maxHeight ? 'auto' : '';
568
+ }
569
+
570
+ /**
571
+ * @description Clamps the menu to `h` when it doesn't fit the viewport.
572
+ * @param {number} h - Target form height in px.
573
+ */
574
+ #clampHeight(h) {
575
+ const chrome = this.form.offsetHeight - this.#listInner.offsetHeight; // padding + border
576
+ this.form.style.height = h + 'px';
577
+ this.#listInner.style.maxHeight = (h - chrome > 0 ? h - chrome : 0) + 'px';
578
+ this.#listInner.style.overflowY = 'auto';
579
+ }
580
+
581
+ /**
582
+ * @description Scrolls the list so the given item is fully visible.
583
+ * @param {Element} item - The item element to reveal.
584
+ */
585
+ #scrollToItem(item) {
586
+ if (!item) return;
587
+
588
+ const list = this.#listInner;
589
+ if (!list.style.maxHeight) return;
590
+ const top = list.getBoundingClientRect().top + list.clientTop;
591
+ const bottom = top + list.clientHeight;
592
+ const { top: itemTop, bottom: itemBottom } = item.getBoundingClientRect();
593
+
594
+ if (itemTop < top) list.scrollTop -= top - itemTop;
595
+ else if (itemBottom > bottom) list.scrollTop += itemBottom - bottom;
596
+ }
597
+
554
598
  /**
555
599
  * @description Highlights and selects an item by index.
556
600
  * @param {number} selectIndex - The index of the item to select.
@@ -569,6 +613,7 @@ class SelectMenu {
569
613
 
570
614
  this.index = selectIndex;
571
615
  this.item = this.items[selectIndex];
616
+ this.#scrollToItem(this.menus[selectIndex]);
572
617
  }
573
618
 
574
619
  /**
@@ -584,7 +629,8 @@ class SelectMenu {
584
629
  const target = this.#refer;
585
630
  form.style.visibility = 'hidden';
586
631
  form.style.display = 'block';
587
- dom.utils.removeClass(form, 'se-select-menu-scroll');
632
+
633
+ this.#resetClamp();
588
634
  dom.utils.addClass(target, 'on');
589
635
 
590
636
  const formW = form.offsetWidth;
@@ -635,7 +681,7 @@ class SelectMenu {
635
681
  h += formT - 4;
636
682
  t -= formT - 4;
637
683
  }
638
- form.style.height = h + 'px';
684
+ this.#clampHeight(h);
639
685
  break;
640
686
  }
641
687
  case 'top':
@@ -645,7 +691,7 @@ class SelectMenu {
645
691
  break;
646
692
  }
647
693
  overH = targetGlobalTop - 4 + sideAddH;
648
- if (overH >= MENU_MIN_HEIGHT) form.style.height = overH + 'px';
694
+ if (overH >= MENU_MIN_HEIGHT) this.#clampHeight(overH);
649
695
  }
650
696
  t = targetOffsetTop - form.offsetHeight + sideAddH;
651
697
  break;
@@ -656,7 +702,7 @@ class SelectMenu {
656
702
  break;
657
703
  }
658
704
  overH = wbottom - 4 + sideAddH;
659
- if (overH >= MENU_MIN_HEIGHT) form.style.height = overH + 'px';
705
+ if (overH >= MENU_MIN_HEIGHT) this.#clampHeight(overH);
660
706
  }
661
707
  t = targetOffsetTop + (side ? 0 : targetHeight);
662
708
  break;
@@ -708,7 +754,7 @@ class SelectMenu {
708
754
  }
709
755
 
710
756
  if (onItemQuerySelector) {
711
- const item = form.firstElementChild.querySelector(onItemQuerySelector);
757
+ const item = this.#listInner.querySelector(onItemQuerySelector);
712
758
  if (item) {
713
759
  this._onItem = item;
714
760
  dom.utils.addClass(item, 'se-select-on');
@@ -794,22 +840,18 @@ class SelectMenu {
794
840
  this.#moveSubmenuItem(1);
795
841
  return;
796
842
  case 'ArrowLeft':
843
+ case 'ArrowRight': {
797
844
  e.preventDefault();
798
845
  e.stopPropagation();
799
- // exit submenu back to parent
800
- this.#inSubmenu = false;
801
- this.#submenuItemIndex = -1;
802
- {
803
- const subData = this.#submenuData.get(this.#activeSubmenuIndex);
804
- if (subData?.element)
805
- dom.utils.removeClass(
806
- subData.element.querySelectorAll('.se-select-item'),
807
- 'se-select-cursor',
808
- );
809
- }
846
+ // Close the submenu and put the cursor back on its trigger row — the same thing ESC
847
+ // does. Either direction closes, so the gesture reads the same in LTR and RTL and
848
+ // there is no writing-direction branch to keep in sync.
849
+ const parentIndex = this.#activeSubmenuIndex;
850
+ this.#closeSubmenu();
851
+ if (parentIndex > -1) this.#selectItem(parentIndex);
810
852
  return;
853
+ }
811
854
  case 'Enter':
812
- case 'Space':
813
855
  if (this.#submenuItemIndex > -1) {
814
856
  e.preventDefault();
815
857
  e.stopPropagation();
@@ -841,24 +883,40 @@ class SelectMenu {
841
883
  moveIndex = 1;
842
884
  }
843
885
  break;
844
- case 'ArrowLeft': // left
886
+ case 'ArrowLeft':
887
+ case 'ArrowRight':
845
888
  e.preventDefault();
846
889
  e.stopPropagation();
847
- moveIndex = -1;
848
- break;
849
- case 'ArrowRight': // right — enter submenu if available
850
- e.preventDefault();
851
- e.stopPropagation();
852
- if (this.index > -1 && this.#submenuData.has(this.index)) {
853
- this.#openSubmenu(this.index);
854
- this.#inSubmenu = true;
855
- this.#moveSubmenuItem(1);
890
+
891
+ if (this.#activeSubmenuIndex > -1) {
892
+ const parentIndex = this.#activeSubmenuIndex;
893
+ this.#closeSubmenu();
894
+ this.#selectItem(parentIndex);
856
895
  return;
857
896
  }
858
- moveIndex = 1;
897
+
898
+ if (this.subEscMethod?.()) return;
899
+
900
+ if (this.index > -1) {
901
+ if (this.#submenuData.has(this.index)) {
902
+ this.#openSubmenu(this.index);
903
+ this.#inSubmenu = true;
904
+ this.#moveSubmenuItem(1);
905
+ return;
906
+ }
907
+
908
+ if (this.subCheckMethod?.(this.index)) {
909
+ this.#select(this.index);
910
+ return;
911
+ }
912
+ }
913
+
914
+ if (!this.horizontal) return;
915
+
916
+ moveIndex = e.code === 'ArrowLeft' ? -1 : 1;
917
+
859
918
  break;
860
919
  case 'Enter':
861
- case 'Space': // enter, space
862
920
  if (this.index > -1) {
863
921
  e.preventDefault();
864
922
  e.stopPropagation();
@@ -875,6 +933,9 @@ class SelectMenu {
875
933
 
876
934
  if (moveIndex) {
877
935
  this.#closeSubmenu();
936
+ // Moving off a row must take its sub-panel with it, otherwise the flyout stays open while the
937
+ // cursor is somewhere else entirely.
938
+ this.subEscMethod?.();
878
939
  this.#moveItem(moveIndex);
879
940
  }
880
941
  }
@@ -1003,6 +1064,12 @@ class SelectMenu {
1003
1064
  #CloseListener_mousedown(e) {
1004
1065
  const eventTarget = dom.query.getEventTarget(e);
1005
1066
  if (this.form.contains(eventTarget)) return;
1067
+ if (
1068
+ this.#$.ui.opendControllers?.some(
1069
+ ({ form }) => form?.contains?.(eventTarget) && !form.contains(this.#refer),
1070
+ )
1071
+ )
1072
+ return;
1006
1073
  if (!this.#refer.contains(eventTarget)) {
1007
1074
  this.close();
1008
1075
  } else if (!dom.check.isInputElement(eventTarget)) {
@@ -924,9 +924,24 @@ class Table extends PluginDropdownFree {
924
924
  * @description Executes the selected action when the table picker is clicked.
925
925
  */
926
926
  #OnClickTablePicker() {
927
+ this.insert(this.#tableXY[0], this.#tableXY[1]);
928
+ }
929
+
930
+ /**
931
+ * @description Insert a table of the given size at the caret and place the caret in its first cell.
932
+ * @param {number} [cols=3] - Column count
933
+ * @param {number} [rows=3] - Row count
934
+ * @returns {boolean} `true` when the table was inserted
935
+ * @example
936
+ * // insert a 3x3 table without going through the size picker
937
+ * editor.plugins.table.insert();
938
+ * editor.plugins.table.insert(4, 2);
939
+ */
940
+ insert(cols, rows) {
941
+ const x = cols > 0 ? cols : Constants.DEFAULT_SIZE[0];
942
+ const y = rows > 0 ? rows : Constants.DEFAULT_SIZE[1];
943
+
927
944
  const oTable = dom.utils.createElement('TABLE');
928
- const x = this.#tableXY[0];
929
- const y = this.#tableXY[1];
930
945
 
931
946
  const body = `<tbody>${`<tr>${CreateCellsString('td', x)}</tr>`.repeat(y)}</tbody>`;
932
947
  const colGroup = `<colgroup>${`<col style="width: ${numbers.get(100 / x, Constants.CELL_DECIMAL_END)}%;">`.repeat(x)}</colgroup>`;
@@ -945,12 +960,14 @@ class Table extends PluginDropdownFree {
945
960
  figure.appendChild(oTable);
946
961
  this.#maxWidth = true;
947
962
 
948
- if (this.$.component.insert(figure, { insertBehavior: 'none' })) {
949
- this.#resetTablePicker();
950
- this.$.menu.dropdownOff();
951
- const target = oTable.querySelector('td div');
952
- this.$.selection.setRange(target, 0, target, 0);
953
- }
963
+ if (!this.$.component.insert(figure, { insertBehavior: 'none' })) return false;
964
+
965
+ this.#resetTablePicker();
966
+ this.$.menu.dropdownOff();
967
+ const target = oTable.querySelector('td div');
968
+ this.$.selection.setRange(target, 0, target, 0);
969
+
970
+ return true;
954
971
  }
955
972
 
956
973
  /**
@@ -279,15 +279,12 @@ export class TableCellService {
279
279
  * @description Sets the unmerge button visibility.
280
280
  */
281
281
  setUnMergeButton() {
282
- if (
282
+ const hasMergedCells =
283
283
  this.findMergedCells(
284
284
  !this.#state.selectedCells?.length ? [this.#state.fixedCell] : this.#state.selectedCells,
285
- ).length > 0
286
- ) {
287
- this.unmergeButton.disabled = false;
288
- } else {
289
- this.unmergeButton.disabled = true;
290
- }
285
+ ).length > 0;
286
+
287
+ this.unmergeButton.style.display = hasMergedCells ? 'block' : 'none';
291
288
  }
292
289
 
293
290
  /**
@@ -2,6 +2,8 @@ export const ROW_SELECT_MARGIN = 6;
2
2
  export const CELL_SELECT_MARGIN = 6;
3
3
  export const CELL_DECIMAL_END = 0;
4
4
 
5
+ export const DEFAULT_SIZE = [3, 3];
6
+
5
7
  export const RESIZE_CELL_CLASS = '.se-table-resize-line';
6
8
  export const RESIZE_CELL_PREV_CLASS = '.se-table-resize-line-prev';
7
9
  export const RESIZE_ROW_CLASS = '.se-table-resize-row';
@@ -31,7 +31,10 @@ const { debounce } = converter;
31
31
  * (plugin names, built-in commands like `'bold'`); objects are custom items with their own `action`.
32
32
  * Required.
33
33
  * @property {number} [delayTime=120] - Debounce delay (ms) before the input is inspected for the trigger.
34
- * @property {number} [limitSize=10] - Maximum number of items shown in the dropdown.
34
+ * @property {number} [limitSize=0] - Maximum number of items kept after filtering. `0` (default) keeps every match
35
+ * - the list scrolls within `maxHeight`, so a cap only hides matches the user can no longer reach.
36
+ * @property {string} [maxHeight='320px'] - Max height of the menu list. Any CSS length; the list scrolls past it.
37
+ * @property {string} [minWidth='200px'] - Min width of the menu.
35
38
  * @property {string} [emptyMessage] - Message shown when no items match the query. If unset, the menu closes on no match.
36
39
  * @property {function(SlashCommandItem, { icons: Object }): string} [renderItem] - Custom item HTML renderer.
37
40
  * Applied only to custom item objects; plugin-name entries always render with the canonical BlockHandle row.
@@ -64,6 +67,15 @@ const { debounce } = converter;
64
67
  * // A container block (BLOCKQUOTE, DIV…): `applyBlock` WRAPS the selected lines → `<blockquote>…</blockquote>`.
65
68
  * action: ($) => $.format.applyBlock(document.createElement('BLOCKQUOTE')),
66
69
  * },
70
+ * {
71
+ * key: 'table',
72
+ * title: 'Table',
73
+ * icon: 'table',
74
+ * // A plugin-name entry (`'table'`) opens that plugin's own UI — for table, the size picker,
75
+ * // which is driven by the pointer. A custom item skips it and inserts straight away, which
76
+ * // keeps the whole gesture on the keyboard: type the trigger, press Enter, done.
77
+ * action: ($) => $.plugins.table.insert(3, 3),
78
+ * },
67
79
  * 'bold',
68
80
  * 'image',
69
81
  * 'blockStyle',
@@ -111,7 +123,9 @@ class SlashCommand extends PluginField {
111
123
  ? pluginOptions.triggerChar
112
124
  : '/';
113
125
  this.#limitSize =
114
- typeof pluginOptions.limitSize === 'number' && pluginOptions.limitSize > 0 ? pluginOptions.limitSize : 10;
126
+ typeof pluginOptions.limitSize === 'number' && pluginOptions.limitSize > 0
127
+ ? pluginOptions.limitSize
128
+ : Infinity;
115
129
  this.#emptyMessage = typeof pluginOptions.emptyMessage === 'string' ? pluginOptions.emptyMessage : '';
116
130
  const delayTime = typeof pluginOptions.delayTime === 'number' ? pluginOptions.delayTime : 120;
117
131
 
@@ -119,11 +133,12 @@ class SlashCommand extends PluginField {
119
133
  items: Array.isArray(pluginOptions.items) ? pluginOptions.items : [],
120
134
  resolveButton: ResolveButton,
121
135
  renderCustomItem: typeof pluginOptions.renderItem === 'function' ? pluginOptions.renderItem : null,
136
+ prepareCommit: () => this.#removeTrigger(),
122
137
  selectMenuParams: {
123
138
  position: 'bottom-left',
124
139
  dir: 'ltr',
125
- minWidth: '200px',
126
- maxHeight: '320px',
140
+ minWidth: typeof pluginOptions.minWidth === 'string' ? pluginOptions.minWidth : '200px',
141
+ maxHeight: typeof pluginOptions.maxHeight === 'string' ? pluginOptions.maxHeight : '320px',
127
142
  closeMethod: () => this.#onMenuClose(),
128
143
  },
129
144
  });
@@ -219,6 +234,32 @@ class SlashCommand extends PluginField {
219
234
  this.#cacheAnchor(anchorNode, lastPos, anchorOffset);
220
235
  }
221
236
 
237
+ /**
238
+ * @description Open the command menu programmatically, with no trigger character typed and the full tem list shown.
239
+ * - Intended for host UI that wants the same menu without the `/` shortcut — e.g. the
240
+ * - block handle's plus button:
241
+ * ```js
242
+ * blockHandle: { onPlusClick: ($, { block }) => $.plugins.slashCommand.open(block) }
243
+ * ```
244
+ * @param {Node} anchorNode - Node the menu anchors to (typically the line the caret sits on).
245
+ * @returns {boolean} `true` if the menu was opened
246
+ */
247
+ open(anchorNode) {
248
+ if (!anchorNode) return false;
249
+
250
+ const filtered = this.#menu.filter('', this.#limitSize);
251
+ if (filtered.length === 0) return false;
252
+
253
+ this.controller.open(anchorNode, null, { isWWTarget: true, initMethod: null, addOffset: null });
254
+ this.#menu.createRows(filtered);
255
+ this.#menu.open();
256
+ this.#menu.setItem(0);
257
+
258
+ this.#cacheAnchor(anchorNode, 0, 0);
259
+
260
+ return true;
261
+ }
262
+
222
263
  /**
223
264
  * @description Close the menu from the plugin itself (invalid query, or after a selection). Flags
224
265
  * the close as internal so `#onMenuClose` does not treat it as a user dismiss.
@@ -289,17 +330,23 @@ class SlashCommand extends PluginField {
289
330
  if (!anchorNode) return false;
290
331
 
291
332
  const triggerChar = this.#triggerChar;
292
- const query = anchorNode.textContent.substring(this.#lastTriggerPos + triggerChar.length, this.#anchorOffset);
293
-
294
- // Remove the trigger + query, leaving the caret at the trigger position so the action
295
- // (insert block, run command, etc.) operates from a clean cursor.
296
- this.$.selection.setRange(anchorNode, this.#lastTriggerPos, anchorNode, this.#anchorOffset);
297
- const range = this.$.selection.getRange();
298
- if (range && !range.collapsed) this.$.html.remove();
333
+ const query = (anchorNode.textContent || '').substring(
334
+ this.#lastTriggerPos + triggerChar.length,
335
+ this.#anchorOffset,
336
+ );
299
337
 
300
- this.#closeMenu();
338
+ if (item.kind !== 'dropdownFree') {
339
+ this.#removeTrigger();
340
+ this.#closeMenu();
341
+ this.#menu.dispatch(item, { triggerChar, query, item: item.raw });
342
+ return;
343
+ }
301
344
 
345
+ // `dispatch` toggles, so picking the row whose flyout is already up (hovered, then picked) closes
346
+ // it and keeps the menu — as BlockHandle does. Only a flyout that never opened leaves nothing to show.
347
+ const hadSubPanel = this.#menu.hasOpenSubPanel();
302
348
  this.#menu.dispatch(item, { triggerChar, query, item: item.raw });
349
+ if (!hadSubPanel && !this.#menu.hasOpenSubPanel()) this.#closeMenu();
303
350
  }
304
351
  }
305
352
 
@@ -20,6 +20,7 @@ declare namespace _default {
20
20
  export let print: string;
21
21
  export let template: string;
22
22
  export let layout: string;
23
+ export let ai: string;
23
24
  export let new_document: string;
24
25
  export let select_all: string;
25
26
  export let line_height: string;
@@ -44,6 +44,7 @@ export namespace A {
44
44
  function deleteEmptyLineMergeNext(formatEl: Element, next: Element): Action;
45
45
  function deleteBrLineRowMerge(rowEndBr: Node): Action;
46
46
  function deleteSoftBreakMerge(br: Node): Action;
47
+ function mergeLineInto(into: HTMLElement, from: HTMLElement): Action;
47
48
  function tabFormatIndent(range: Range, formatEl: Element, shift: boolean): Action;
48
49
  function caretScrollTo(range: Range): Action;
49
50
  function enterLineAddDefault(formatEl: Element): Action;
@@ -37,6 +37,10 @@ declare const _default: {
37
37
  'delete.list.removeNested': ({ ports, ctx }: EffectContext_keydown, { range, formatEl, rangeEl }: any) => void;
38
38
  /** @action deleteEmptyLineMergeNext — remove an empty line, move caret to the start of the next line */
39
39
  'delete.emptyLine.mergeNext': ({ ports }: EffectContext_keydown, { formatEl, next }: any) => void;
40
+ /**
41
+ * @action mergeLineInto — Merge one line into another across a block boundary.
42
+ */
43
+ 'line.merge': ({ ports }: EffectContext_keydown, { into, from }: any) => void;
40
44
  /** @action deleteBrLineRowMerge — remove an empty row inside a brLine (PRE), pull the next row up */
41
45
  'delete.brline.rowMerge': ({ ports }: EffectContext_keydown, { rowEndBr }: any) => void;
42
46
  /** @action deleteSoftBreakMerge */
@@ -29,6 +29,7 @@ import type {} from '../../typedef';
29
29
  * @property {(...args: Parameters<Format['isNormalLine']>) => ReturnType<Format['isNormalLine']>} isNormalLine
30
30
  * @property {(...args: Parameters<Format['isBrLine']>) => ReturnType<Format['isBrLine']>} isBrLine
31
31
  * @property {(...args: Parameters<Format['isClosureBrLine']>) => ReturnType<Format['isClosureBrLine']>} isClosureBrLine
32
+ * @property {(...args: Parameters<Format['isBlock']>) => ReturnType<Format['isBlock']>} isBlock
32
33
  * @property {(...args: Parameters<Format['isClosureBlock']>) => ReturnType<Format['isClosureBlock']>} isClosureBlock
33
34
  * @property {(...args: Parameters<Format['isEdgeLine']>) => ReturnType<Format['isEdgeLine']>} isEdgeLine
34
35
  * @property {(...args: Parameters<Format['removeBlock']>) => ReturnType<Format['removeBlock']>} removeBlock
@@ -98,6 +99,7 @@ export function makePorts(
98
99
  isNormalLine: (n: any) => n is HTMLElement;
99
100
  isBrLine: (n: any) => n is HTMLElement;
100
101
  isClosureBrLine: (n: any) => n is HTMLElement;
102
+ isBlock: (n: any) => n is HTMLElement;
101
103
  isClosureBlock: (n: any) => n is HTMLElement;
102
104
  isEdgeLine: (node: any, offset: any, dir: any) => node is HTMLElement;
103
105
  removeBlock: (
@@ -206,6 +208,7 @@ export type FormatPorts = {
206
208
  isNormalLine: (...args: Parameters<Format['isNormalLine']>) => ReturnType<Format['isNormalLine']>;
207
209
  isBrLine: (...args: Parameters<Format['isBrLine']>) => ReturnType<Format['isBrLine']>;
208
210
  isClosureBrLine: (...args: Parameters<Format['isClosureBrLine']>) => ReturnType<Format['isClosureBrLine']>;
211
+ isBlock: (...args: Parameters<Format['isBlock']>) => ReturnType<Format['isBlock']>;
209
212
  isClosureBlock: (...args: Parameters<Format['isClosureBlock']>) => ReturnType<Format['isClosureBlock']>;
210
213
  isEdgeLine: (...args: Parameters<Format['isEdgeLine']>) => ReturnType<Format['isEdgeLine']>;
211
214
  removeBlock: (...args: Parameters<Format['removeBlock']>) => ReturnType<Format['removeBlock']>;