suneditor 3.1.4 → 3.2.0

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 (216) hide show
  1. package/dist/suneditor-contents.min.css +1 -1
  2. package/dist/suneditor.min.css +28 -1
  3. package/dist/suneditor.min.js +1 -1
  4. package/package.json +2 -1
  5. package/src/assets/design/color.css +12 -0
  6. package/src/assets/design/size.css +9 -2
  7. package/src/assets/icons/defaultIcons.js +96 -47
  8. package/src/assets/suneditor-contents.css +3 -2
  9. package/src/assets/suneditor.css +480 -31
  10. package/src/core/config/contextProvider.js +2 -1
  11. package/src/core/config/eventManager.js +41 -2
  12. package/src/core/config/optionProvider.js +23 -2
  13. package/src/core/editor.js +32 -14
  14. package/src/core/event/actions/index.js +76 -9
  15. package/src/core/event/effects/common.registry.js +2 -1
  16. package/src/core/event/effects/keydown.registry.js +169 -33
  17. package/src/core/event/effects/ruleHelpers.js +20 -6
  18. package/src/core/event/eventOrchestrator.js +136 -23
  19. package/src/core/event/handlers/handler_toolbar.js +4 -2
  20. package/src/core/event/handlers/handler_ww_dragDrop.js +10 -0
  21. package/src/core/event/handlers/handler_ww_input.js +9 -2
  22. package/src/core/event/handlers/handler_ww_key.js +86 -17
  23. package/src/core/event/handlers/handler_ww_mouse.js +45 -8
  24. package/src/core/event/ports.js +23 -5
  25. package/src/core/event/reducers/keydown.reducer.js +15 -2
  26. package/src/core/event/rules/keydown.rule.arrow.js +12 -2
  27. package/src/core/event/rules/keydown.rule.backspace.js +110 -15
  28. package/src/core/event/rules/keydown.rule.delete.js +62 -7
  29. package/src/core/event/rules/keydown.rule.enter.js +62 -31
  30. package/src/core/event/support/defaultLineManager.js +9 -2
  31. package/src/core/event/support/selectionState.js +13 -3
  32. package/src/core/kernel/coreKernel.js +1 -0
  33. package/src/core/logic/dom/char.js +13 -3
  34. package/src/core/logic/dom/format.js +171 -39
  35. package/src/core/logic/dom/html.js +350 -72
  36. package/src/core/logic/dom/inline.js +229 -34
  37. package/src/core/logic/dom/listFormat.js +111 -20
  38. package/src/core/logic/dom/nodeTransform.js +36 -11
  39. package/src/core/logic/dom/offset.js +116 -31
  40. package/src/core/logic/dom/selection.js +147 -29
  41. package/src/core/logic/panel/blockHandle.js +785 -0
  42. package/src/core/logic/panel/blockResolver.js +278 -0
  43. package/src/core/logic/panel/finder.js +16 -6
  44. package/src/core/logic/panel/menu.js +119 -18
  45. package/src/core/logic/panel/toolbar.js +75 -19
  46. package/src/core/logic/panel/viewer.js +76 -20
  47. package/src/core/logic/shell/_commandExecutor.js +44 -9
  48. package/src/core/logic/shell/commandDispatcher.js +46 -4
  49. package/src/core/logic/shell/component.js +102 -25
  50. package/src/core/logic/shell/focusManager.js +13 -3
  51. package/src/core/logic/shell/history.js +21 -6
  52. package/src/core/logic/shell/pluginManager.js +21 -13
  53. package/src/core/logic/shell/shortcuts.js +9 -1
  54. package/src/core/logic/shell/ui.js +139 -37
  55. package/src/core/schema/frameContext.js +34 -3
  56. package/src/core/schema/options.js +46 -4
  57. package/src/core/section/constructor.js +537 -93
  58. package/src/core/section/documentType.js +35 -6
  59. package/src/helper/converter.js +24 -7
  60. package/src/helper/dom/domCheck.js +25 -5
  61. package/src/helper/dom/domQuery.js +2 -1
  62. package/src/helper/dom/domUtils.js +17 -4
  63. package/src/helper/env.js +11 -2
  64. package/src/helper/keyCodeMap.js +6 -2
  65. package/src/helper/markdown.js +24 -5
  66. package/src/helper/msOffice.js +9 -2
  67. package/src/interfaces/plugins.js +1 -1
  68. package/src/langs/ckb.js +0 -1
  69. package/src/langs/cs.js +0 -1
  70. package/src/langs/da.js +0 -1
  71. package/src/langs/de.js +0 -1
  72. package/src/langs/en.js +0 -1
  73. package/src/langs/es.js +0 -1
  74. package/src/langs/fa.js +0 -1
  75. package/src/langs/fr.js +0 -1
  76. package/src/langs/he.js +0 -1
  77. package/src/langs/hu.js +0 -1
  78. package/src/langs/index.js +27 -1
  79. package/src/langs/it.js +0 -1
  80. package/src/langs/ja.js +0 -1
  81. package/src/langs/km.js +0 -1
  82. package/src/langs/ko.js +0 -1
  83. package/src/langs/lv.js +0 -1
  84. package/src/langs/nl.js +0 -1
  85. package/src/langs/pl.js +0 -1
  86. package/src/langs/pt_br.js +0 -1
  87. package/src/langs/ro.js +0 -1
  88. package/src/langs/ru.js +0 -1
  89. package/src/langs/se.js +0 -1
  90. package/src/langs/tr.js +0 -1
  91. package/src/langs/uk.js +0 -1
  92. package/src/langs/ur.js +0 -1
  93. package/src/langs/zh_cn.js +0 -1
  94. package/src/modules/contract/Browser.js +58 -14
  95. package/src/modules/contract/ColorPicker.js +12 -4
  96. package/src/modules/contract/Controller.js +37 -7
  97. package/src/modules/contract/Figure.js +156 -36
  98. package/src/modules/contract/HueSlider.js +27 -4
  99. package/src/modules/contract/Modal.js +50 -10
  100. package/src/modules/manager/FileManager.js +50 -8
  101. package/src/modules/ui/CommandMenu.js +597 -0
  102. package/src/modules/ui/ModalAnchorEditor.js +33 -6
  103. package/src/modules/ui/SelectMenu.js +481 -36
  104. package/src/modules/ui/index.js +1 -0
  105. package/src/plugins/browser/audioGallery.js +2 -1
  106. package/src/plugins/browser/fileBrowser.js +9 -2
  107. package/src/plugins/browser/fileGallery.js +2 -1
  108. package/src/plugins/browser/imageGallery.js +18 -3
  109. package/src/plugins/browser/videoGallery.js +13 -4
  110. package/src/plugins/command/blockquote.js +6 -1
  111. package/src/plugins/command/codeBlock.js +43 -5
  112. package/src/plugins/command/exportPDF.js +20 -4
  113. package/src/plugins/command/fileUpload.js +46 -11
  114. package/src/plugins/command/list_bulleted.js +6 -1
  115. package/src/plugins/command/list_numbered.js +6 -1
  116. package/src/plugins/dropdown/align.js +21 -33
  117. package/src/plugins/dropdown/backgroundColor.js +10 -2
  118. package/src/plugins/dropdown/blockStyle.js +17 -22
  119. package/src/plugins/dropdown/font.js +34 -34
  120. package/src/plugins/dropdown/hr.js +15 -43
  121. package/src/plugins/dropdown/layout.js +10 -27
  122. package/src/plugins/dropdown/lineHeight.js +10 -33
  123. package/src/plugins/dropdown/list.js +19 -25
  124. package/src/plugins/dropdown/paragraphStyle.js +16 -24
  125. package/src/plugins/dropdown/table/index.js +72 -17
  126. package/src/plugins/dropdown/table/render/table.html.js +3 -1
  127. package/src/plugins/dropdown/table/services/table.cell.js +33 -7
  128. package/src/plugins/dropdown/table/services/table.clipboard.js +5 -1
  129. package/src/plugins/dropdown/table/services/table.grid.js +40 -9
  130. package/src/plugins/dropdown/table/services/table.resize.js +55 -11
  131. package/src/plugins/dropdown/table/services/table.selection.js +27 -7
  132. package/src/plugins/dropdown/table/services/table.style.js +120 -20
  133. package/src/plugins/dropdown/template.js +13 -29
  134. package/src/plugins/dropdown/textStyle.js +22 -35
  135. package/src/plugins/field/autocomplete.js +8 -4
  136. package/src/plugins/field/slashCommand.js +297 -0
  137. package/src/plugins/index.js +3 -0
  138. package/src/plugins/input/fontSize.js +37 -8
  139. package/src/plugins/input/pageNavigator.js +7 -1
  140. package/src/plugins/modal/audio.js +71 -17
  141. package/src/plugins/modal/drawing.js +41 -11
  142. package/src/plugins/modal/embed.js +92 -22
  143. package/src/plugins/modal/image/index.js +114 -28
  144. package/src/plugins/modal/image/services/image.size.js +9 -2
  145. package/src/plugins/modal/image/services/image.upload.js +38 -4
  146. package/src/plugins/modal/link.js +9 -2
  147. package/src/plugins/modal/math.js +10 -2
  148. package/src/plugins/modal/video/index.js +112 -26
  149. package/src/plugins/modal/video/render/video.html.js +4 -1
  150. package/src/plugins/modal/video/services/video.size.js +15 -4
  151. package/src/plugins/modal/video/services/video.upload.js +10 -2
  152. package/src/plugins/popup/anchor.js +14 -2
  153. package/src/suneditor.js +8 -2
  154. package/src/themes/cobalt.css +12 -0
  155. package/src/themes/cream.css +12 -0
  156. package/src/themes/dark.css +12 -0
  157. package/src/themes/midnight.css +12 -0
  158. package/src/typedef.js +32 -21
  159. package/types/assets/icons/defaultIcons.d.ts +1 -0
  160. package/types/core/config/contextProvider.d.ts +7 -1
  161. package/types/core/config/eventManager.d.ts +37 -4
  162. package/types/core/config/optionProvider.d.ts +5 -1
  163. package/types/core/event/actions/index.d.ts +44 -6
  164. package/types/core/event/effects/keydown.registry.d.ts +37 -7
  165. package/types/core/event/effects/ruleHelpers.d.ts +6 -1
  166. package/types/core/event/eventOrchestrator.d.ts +6 -1
  167. package/types/core/event/handlers/handler_ww_clipboard.d.ts +15 -3
  168. package/types/core/event/handlers/handler_ww_dragDrop.d.ts +14 -2
  169. package/types/core/event/handlers/handler_ww_input.d.ts +10 -2
  170. package/types/core/event/handlers/handler_ww_key.d.ts +10 -2
  171. package/types/core/event/handlers/handler_ww_mouse.d.ts +25 -5
  172. package/types/core/event/ports.d.ts +7 -2
  173. package/types/core/event/rules/keydown.rule.backspace.d.ts +6 -1
  174. package/types/core/kernel/store.d.ts +4 -1
  175. package/types/core/logic/dom/format.d.ts +32 -14
  176. package/types/core/logic/dom/html.d.ts +11 -3
  177. package/types/core/logic/dom/nodeTransform.d.ts +6 -6
  178. package/types/core/logic/panel/blockHandle.d.ts +64 -0
  179. package/types/core/logic/panel/blockResolver.d.ts +50 -0
  180. package/types/core/logic/panel/menu.d.ts +72 -3
  181. package/types/core/logic/shell/commandDispatcher.d.ts +8 -0
  182. package/types/core/logic/shell/component.d.ts +5 -3
  183. package/types/core/logic/shell/shortcuts.d.ts +10 -1
  184. package/types/core/logic/shell/ui.d.ts +7 -1
  185. package/types/core/schema/context.d.ts +7 -1
  186. package/types/core/schema/frameContext.d.ts +36 -4
  187. package/types/core/schema/options.d.ts +83 -2
  188. package/types/core/section/constructor.d.ts +38 -1
  189. package/types/events.d.ts +208 -32
  190. package/types/helper/converter.d.ts +4 -2
  191. package/types/helper/dom/domCheck.d.ts +12 -2
  192. package/types/helper/dom/domQuery.d.ts +35 -7
  193. package/types/helper/dom/domUtils.d.ts +4 -1
  194. package/types/interfaces/plugins.d.ts +2 -2
  195. package/types/langs/_Lang.d.ts +0 -1
  196. package/types/modules/contract/ColorPicker.d.ts +2 -2
  197. package/types/modules/contract/Controller.d.ts +7 -0
  198. package/types/modules/contract/Figure.d.ts +31 -4
  199. package/types/modules/ui/CommandMenu.d.ts +262 -0
  200. package/types/modules/ui/SelectMenu.d.ts +56 -3
  201. package/types/modules/ui/index.d.ts +1 -0
  202. package/types/plugins/browser/audioGallery.d.ts +1 -1
  203. package/types/plugins/browser/fileBrowser.d.ts +1 -1
  204. package/types/plugins/browser/fileGallery.d.ts +1 -1
  205. package/types/plugins/browser/imageGallery.d.ts +1 -1
  206. package/types/plugins/browser/videoGallery.d.ts +1 -1
  207. package/types/plugins/dropdown/align.d.ts +1 -1
  208. package/types/plugins/dropdown/blockStyle.d.ts +3 -1
  209. package/types/plugins/dropdown/table/index.d.ts +4 -1
  210. package/types/plugins/dropdown/table/services/table.clipboard.d.ts +5 -1
  211. package/types/plugins/dropdown/table/services/table.grid.d.ts +10 -2
  212. package/types/plugins/field/autocomplete.d.ts +0 -1
  213. package/types/plugins/field/slashCommand.d.ts +165 -0
  214. package/types/plugins/index.d.ts +3 -0
  215. package/types/plugins/modal/link.d.ts +4 -1
  216. package/types/typedef.d.ts +48 -23
@@ -16,8 +16,16 @@ 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
22
  * @property {string} [maxHeight] Optional max-height CSS value (e.g. `"200px"`). Enables scrolling when items exceed this height.
20
23
  * @property {string} [minWidth] Optional min-width CSS value (e.g. `"130px"`).
24
+ * @property {*} [keydownTarget] Optional override for the keyboard navigation target. By default `on()` listens
25
+ * - on the iframe `contentWindow` (`_ww`) when the refer isn't an input — appropriate when the
26
+ * - refer is inside the wysiwyg. Set this to `window` (parent) for menus whose refer lives in
27
+ * - the parent doc (e.g. BlockHandle's dragBtn in `carrierWrapper`). Also avoids
28
+ * - cross-origin/sandboxed iframe `addEventListener` errors.
21
29
  */
22
30
 
23
31
  /**
@@ -35,11 +43,22 @@ class SelectMenu {
35
43
 
36
44
  #refer = null;
37
45
  #keydownTarget = null;
46
+ #keydownTargetOverride = null;
38
47
  #selectMethod = null;
39
48
  #bindClose_key = null;
40
49
  #bindClose_mousedown = null;
41
50
  #bindClose_click = null;
51
+ #bindSubmenuReposition = null;
42
52
  #events = null;
53
+ #lastMainPosition = null;
54
+ #lastSubPosition = null;
55
+
56
+ // submenu
57
+ #submenuData = new Map();
58
+ #activeSubmenuIndex = -1;
59
+ #submenuItemIndex = -1;
60
+ #inSubmenu = false;
61
+ #submenuHoverTimer = null;
43
62
 
44
63
  /**
45
64
  * @constructor
@@ -66,11 +85,21 @@ class SelectMenu {
66
85
  this.horizontal = !!this.splitNum;
67
86
  this.openMethod = params.openMethod;
68
87
  this.closeMethod = params.closeMethod;
88
+ this.subEscMethod = params.subEscMethod || null;
69
89
  this.maxHeight = params.maxHeight || '';
70
90
  this.minWidth = params.minWidth || '';
91
+ this.#keydownTargetOverride = params.keydownTarget || null;
71
92
 
72
- this.#dirPosition = /^(left|right)$/.test(this.position) ? (this.position === 'left' ? 'right' : 'left') : this.position;
73
- this.#dirSubPosition = /^(left|right)$/.test(this.subPosition) ? (this.subPosition === 'left' ? 'right' : 'left') : this.subPosition;
93
+ this.#dirPosition = /^(left|right)$/.test(this.position)
94
+ ? this.position === 'left'
95
+ ? 'right'
96
+ : 'left'
97
+ : this.position;
98
+ this.#dirSubPosition = /^(left|right)$/.test(this.subPosition)
99
+ ? this.subPosition === 'left'
100
+ ? 'right'
101
+ : 'left'
102
+ : this.subPosition;
74
103
  this.#textDirDiff = params.dir === 'ltr' ? false : params.dir === 'rtl' ? true : null;
75
104
 
76
105
  this.#eventHandlers = {
@@ -79,16 +108,41 @@ class SelectMenu {
79
108
  click: this.#OnClick_list.bind(this),
80
109
  keydown: this.#OnKeyDown_refer.bind(this),
81
110
  };
82
- this.#globalEventHandlers = { keydown: this.#CloseListener_key.bind(this), mousedown: this.#CloseListener_mousedown.bind(this), click: this.#CloseListener_click.bind(this) };
111
+ this.#globalEventHandlers = {
112
+ keydown: this.#CloseListener_key.bind(this),
113
+ mousedown: this.#CloseListener_mousedown.bind(this),
114
+ click: this.#CloseListener_click.bind(this),
115
+ submenuReposition: this.#OnSubmenuReposition.bind(this),
116
+ };
83
117
  }
84
118
 
85
119
  /**
86
120
  * @description Creates the select menu items.
87
- * @param {Array<string>|SunEditor.NodeCollection} items - Command list of selectable items.
88
- * @param {Array<string>|SunEditor.NodeCollection} [menus] - Optional list of menu display elements; defaults to `items`.
121
+ * @param {Array<*>} items - Selectable items.
122
+ * - Plain entry: any value (string/object); passed to the `selectMethod` callback when picked.
123
+ * - Submenu entry: `{ children: Array<*>, childMenus?: Array<string|HTMLElement> }` —
124
+ * `children` are the child values delivered to `selectMethod` on selection; `childMenus`
125
+ * is the optional display content for each child (HTML string or `HTMLElement`). When
126
+ * omitted, `children` doubles as the display content.
127
+ * @param {Array<string>|SunEditor.NodeCollection} [menus] - Optional list of display elements
128
+ * (HTML strings or nodes) for the top-level rows. Defaults to `items`. For submenu entries
129
+ * this controls the parent row's content; child rows use `childMenus` (or `children`).
130
+ * @example
131
+ * // Submenu — "List" opens a hover submenu of UL/OL options
132
+ * selectMenu.create(
133
+ * [{ children: ['ul', 'ol'], childMenus: ['<i>•</i> Bulleted', '<i>1.</i> Numbered'] }],
134
+ * ['List']
135
+ * );
89
136
  */
90
137
  create(items, menus) {
91
138
  this.form.firstElementChild.innerHTML = '';
139
+
140
+ // remove existing submenu elements from form
141
+ for (const [, data] of this.#submenuData) {
142
+ data.element?.remove();
143
+ }
144
+ this.#submenuData.clear();
145
+
92
146
  menus ||= items;
93
147
  let html = '';
94
148
  for (let i = 0, len = menus.length; i < len; i++) {
@@ -96,12 +150,46 @@ class SelectMenu {
96
150
  this.#createFormat(html);
97
151
  html = '';
98
152
  }
99
- html += `<li class="se-select-item" data-index="${i}">${typeof menus[i] === 'string' ? menus[i] : /** @type {HTMLElement} */ (menus[i]).outerHTML}</li>`;
153
+
154
+ const item = items[i];
155
+ const menuContent =
156
+ typeof menus[i] === 'string' ? menus[i] : /** @type {HTMLElement} */ (menus[i]).outerHTML;
157
+ const itemObj = /** @type {{children?: Array, childMenus?: Array}} */ (
158
+ item && typeof item === 'object' ? item : {}
159
+ );
160
+ const hasChildren = itemObj.children?.length > 0;
161
+
162
+ if (hasChildren) {
163
+ let subHtml = '';
164
+ const childMenus = itemObj.childMenus || itemObj.children;
165
+ for (let c = 0; c < childMenus.length; c++) {
166
+ subHtml += `<li class="se-select-item" data-parent-index="${i}" data-child-index="${c}">${typeof childMenus[c] === 'string' ? childMenus[c] : childMenus[c].outerHTML}</li>`;
167
+ }
168
+ html +=
169
+ `<li class="se-select-item se-has-submenu" data-index="${i}">${menuContent}` +
170
+ `<span class="se-submenu-arrow">${this.#$.icons.menu_arrow_right}</span></li>`;
171
+
172
+ // `popover: manual` lifts the submenu into the top layer so it escapes any
173
+ // `overflow: hidden` on the form's ancestors (toolbar, dropdown panels, ...).
174
+ const subEl = dom.utils.createElement(
175
+ 'DIV',
176
+ { class: 'se-select-submenu', popover: 'manual', 'data-parent-index': String(i) },
177
+ `<ul class="se-list-basic se-list-checked">${subHtml}</ul>`,
178
+ );
179
+ this.#submenuData.set(i, { items: itemObj.children, menus: childMenus, element: subEl });
180
+ } else {
181
+ html += `<li class="se-select-item" data-index="${i}">${menuContent}</li>`;
182
+ }
100
183
  }
101
184
  this.#createFormat(html);
102
185
 
186
+ // append submenu elements to form (outside se-list-inner)
187
+ for (const [, data] of this.#submenuData) {
188
+ this.form.appendChild(data.element);
189
+ }
190
+
103
191
  this.items = /** @type {Array<string|Node>} */ (items);
104
- this.menus = Array.from(this.form.querySelectorAll('li'));
192
+ this.menus = Array.from(this.form.querySelectorAll('li[data-index]'));
105
193
  this.menuLen = this.menus.length;
106
194
  }
107
195
 
@@ -119,7 +207,9 @@ class SelectMenu {
119
207
  */
120
208
  on(referElement, selectMethod, attr = {}) {
121
209
  this.#refer = /** @type {HTMLElement} */ (referElement);
122
- this.#keydownTarget = dom.check.isInputElement(referElement) ? referElement : this.#$.frameContext.get('_ww');
210
+ this.#keydownTarget =
211
+ this.#keydownTargetOverride ||
212
+ (dom.check.isInputElement(referElement) ? referElement : this.#$.frameContext.get('_ww'));
123
213
  this.#selectMethod = selectMethod;
124
214
 
125
215
  let innerStyle = '';
@@ -129,7 +219,10 @@ class SelectMenu {
129
219
  this.form = dom.utils.createElement(
130
220
  'DIV',
131
221
  {
132
- class: 'se-select-menu' + (attr.class ? ' ' + attr.class : ''),
222
+ class:
223
+ 'se-select-menu' +
224
+ (this.#textDirDiff === true ? ' se-rtl' : '') +
225
+ (attr.class ? ' ' + attr.class : ''),
133
226
  style: attr.style || '',
134
227
  },
135
228
  '<div class="se-list-inner"' + (innerStyle ? ' style="' + innerStyle + '"' : '') + '></div>',
@@ -161,12 +254,46 @@ class SelectMenu {
161
254
  this.#addEvents();
162
255
  this.#addGlobalEvent();
163
256
  const positionItems = position ? position.split('-') : [];
164
- const mainPosition = positionItems[0] || (this.#textDirDiff !== null && this.#textDirDiff !== this.#$.options.get('_rtl') ? this.#dirPosition : this.position);
165
- const subPosition = positionItems[1] || (this.#textDirDiff !== null && this.#textDirDiff !== this.#$.options.get('_rtl') ? this.#dirSubPosition : this.subPosition);
257
+ const mainPosition =
258
+ positionItems[0] ||
259
+ (this.#textDirDiff !== null && this.#textDirDiff !== this.#$.options.get('_rtl')
260
+ ? this.#dirPosition
261
+ : this.position);
262
+ const subPosition =
263
+ positionItems[1] ||
264
+ (this.#textDirDiff !== null && this.#textDirDiff !== this.#$.options.get('_rtl')
265
+ ? this.#dirSubPosition
266
+ : this.subPosition);
267
+ this.#lastMainPosition = mainPosition;
268
+ this.#lastSubPosition = subPosition;
166
269
  this.#setPosition(mainPosition, subPosition, onItemQuerySelector);
167
270
  this.isOpen = true;
168
271
  }
169
272
 
273
+ /**
274
+ * @description Re-runs positioning using the same direction the menu was opened with.
275
+ * Use when the reference element has moved (e.g. scroll repositioned the trigger) but
276
+ * the menu should stay open and follow.
277
+ */
278
+ reposition() {
279
+ if (!this.isOpen || !this.#lastMainPosition) return;
280
+ this.#setPosition(this.#lastMainPosition, this.#lastSubPosition);
281
+ }
282
+
283
+ /**
284
+ * @description Soft-hide / soft-show without changing open state.
285
+ * close listeners (outside click, ESC) keep working, but is visually hidden until the trigger comes back.
286
+ */
287
+ setHidden(hidden) {
288
+ if (!this.isOpen) return;
289
+ if (hidden) {
290
+ this.#closeSubmenu();
291
+ this.form.style.display = 'none';
292
+ } else {
293
+ this.reposition();
294
+ }
295
+ }
296
+
170
297
  /**
171
298
  * @description Select menu close
172
299
  */
@@ -197,6 +324,198 @@ class SelectMenu {
197
324
  this.#selectItem(index);
198
325
  }
199
326
 
327
+ /**
328
+ * @description Whether a native submenu is currently open. Used by owners (e.g. a Controller) to
329
+ * let ESC dismiss only the open submenu instead of the whole menu.
330
+ * @returns {boolean}
331
+ */
332
+ hasOpenSubmenu() {
333
+ return this.#activeSubmenuIndex > -1;
334
+ }
335
+
336
+ /**
337
+ * @description Opens the submenu for the given parent index.
338
+ * @param {number} parentIndex
339
+ */
340
+ #openSubmenu(parentIndex) {
341
+ if (this.#activeSubmenuIndex === parentIndex) return;
342
+ this.#closeSubmenu();
343
+
344
+ const parentLi = this.menus[parentIndex];
345
+ if (!parentLi || !dom.utils.hasClass(parentLi, 'se-has-submenu')) return;
346
+
347
+ dom.utils.addClass(parentLi, 'se-submenu-open');
348
+ this.#activeSubmenuIndex = parentIndex;
349
+ this.#submenuItemIndex = -1;
350
+ this.#inSubmenu = false;
351
+
352
+ const data = this.#submenuData.get(parentIndex);
353
+ const sub = data?.element;
354
+ if (sub) {
355
+ // Show first so the top-layer containing block (viewport) is in effect when we
356
+ const supportsPopover = typeof sub.showPopover === 'function';
357
+ sub.style.visibility = 'hidden';
358
+
359
+ if (supportsPopover) {
360
+ if (!sub.matches(':popover-open')) sub.showPopover();
361
+ } else {
362
+ sub.style.display = 'block';
363
+ }
364
+
365
+ this.#positionSubmenu(parentLi, sub, supportsPopover);
366
+ sub.style.visibility = '';
367
+
368
+ // Submenu is top-layer popover (viewport-fixed), but the parent form is in document
369
+ // flow (absolute) and scrolls with the page/container. Re-run positioning on scroll
370
+ // so the submenu stays anchored to the parent LI as it moves.
371
+ this.#bindSubmenuReposition = this.#$.eventManager.addGlobalEvent(
372
+ 'scroll',
373
+ this.#globalEventHandlers.submenuReposition,
374
+ true,
375
+ );
376
+ }
377
+ }
378
+
379
+ /**
380
+ * @description Position the submenu element next to the parent menu
381
+ * @param {HTMLElement} parentLi
382
+ * @param {HTMLElement} sub
383
+ * @param {boolean} supportsPopover
384
+ */
385
+ #positionSubmenu(parentLi, sub, supportsPopover) {
386
+ // In the top layer the containing block is the viewport; otherwise it's the form.
387
+ const formRect = supportsPopover ? { top: 0, left: 0 } : this.form.getBoundingClientRect();
388
+
389
+ const parentRect = parentLi.getBoundingClientRect();
390
+ const subW = sub.offsetWidth;
391
+ const subH = sub.offsetHeight;
392
+ const vpW = _w.innerWidth;
393
+ const vpH = _w.innerHeight;
394
+
395
+ // Horizontal: open in the arrow's direction — RTL prefers left of parent, LTR prefers
396
+ // right. Flip to the opposite side if the preferred side overflows; if both overflow,
397
+ // keep the smaller-overflow side and shift inward by exactly that amount.
398
+ const rightVP = parentRect.right;
399
+ const leftVP = parentRect.left - subW;
400
+ const rightOverflow = Math.max(0, rightVP + subW - vpW);
401
+ const leftOverflow = Math.max(0, -leftVP);
402
+ const preferLeft = this.#textDirDiff === true;
403
+
404
+ let leftPx;
405
+ if (preferLeft) {
406
+ if (leftOverflow === 0) leftPx = leftVP;
407
+ else if (rightOverflow === 0) leftPx = rightVP;
408
+ else if (leftOverflow <= rightOverflow) leftPx = leftVP + leftOverflow;
409
+ else leftPx = rightVP - rightOverflow;
410
+ } else {
411
+ if (rightOverflow === 0) leftPx = rightVP;
412
+ else if (leftOverflow === 0) leftPx = leftVP;
413
+ else if (rightOverflow <= leftOverflow) leftPx = rightVP - rightOverflow;
414
+ else leftPx = leftVP + leftOverflow;
415
+ }
416
+
417
+ sub.style.left = leftPx - formRect.left + 'px';
418
+ sub.style.right = '';
419
+
420
+ // Vertical: try top-aligned with parent; flip to bottom-anchored if it overflows
421
+ // downward; if both directions overflow (submenu taller than viewport), keep the
422
+ // side with the smaller overflow and shift inward by exactly that amount.
423
+ const topVP = parentRect.top;
424
+ const bottomVP = parentRect.bottom - subH;
425
+ const downOverflow = Math.max(0, topVP + subH - vpH);
426
+ const upOverflow = Math.max(0, -bottomVP);
427
+
428
+ let topPx;
429
+ if (downOverflow === 0) {
430
+ topPx = topVP;
431
+ } else if (upOverflow === 0) {
432
+ topPx = bottomVP;
433
+ } else if (downOverflow <= upOverflow) {
434
+ topPx = topVP - downOverflow;
435
+ } else {
436
+ topPx = bottomVP + upOverflow;
437
+ }
438
+
439
+ sub.style.top = topPx - formRect.top + 'px';
440
+ }
441
+
442
+ /**
443
+ * @description Scroll/resize callback while submenu is open — re-runs positioning so the
444
+ * top-layer popover follows the parent LI which scrolls with the document.
445
+ */
446
+ #OnSubmenuReposition() {
447
+ if (this.#activeSubmenuIndex < 0) return;
448
+ const data = this.#submenuData.get(this.#activeSubmenuIndex);
449
+ const sub = data?.element;
450
+ const parentLi = this.menus[this.#activeSubmenuIndex];
451
+ if (!sub || !parentLi) return;
452
+ this.#positionSubmenu(parentLi, sub, typeof sub.showPopover === 'function');
453
+ }
454
+
455
+ /**
456
+ * @description Closes any open submenu.
457
+ */
458
+ #closeSubmenu() {
459
+ if (this.#submenuHoverTimer) {
460
+ _w.clearTimeout(this.#submenuHoverTimer);
461
+ this.#submenuHoverTimer = null;
462
+ }
463
+ this.#bindSubmenuReposition &&= this.#$.eventManager.removeGlobalEvent(this.#bindSubmenuReposition);
464
+ if (this.#activeSubmenuIndex > -1) {
465
+ const parentLi = this.menus[this.#activeSubmenuIndex];
466
+ if (parentLi) dom.utils.removeClass(parentLi, 'se-submenu-open');
467
+
468
+ const data = this.#submenuData.get(this.#activeSubmenuIndex);
469
+ if (data?.element) {
470
+ if (typeof data.element.hidePopover === 'function') {
471
+ if (data.element.matches(':popover-open')) data.element.hidePopover();
472
+ } else {
473
+ data.element.style.display = '';
474
+ }
475
+ dom.utils.removeClass(data.element.querySelectorAll('.se-select-item'), 'se-select-cursor');
476
+ }
477
+ }
478
+ this.#activeSubmenuIndex = -1;
479
+ this.#submenuItemIndex = -1;
480
+ this.#inSubmenu = false;
481
+ }
482
+
483
+ /**
484
+ * @description Selects a child item from an open submenu.
485
+ * @param {number} parentIndex
486
+ * @param {number} childIndex
487
+ */
488
+ #selectChild(parentIndex, childIndex) {
489
+ const data = this.#submenuData.get(parentIndex);
490
+ if (!data) return;
491
+ if (this.checkList) {
492
+ const childLi = data.element?.querySelectorAll('.se-select-item')[childIndex];
493
+ if (childLi) dom.utils.toggleClass(childLi, 'se-checked');
494
+ }
495
+ this.#selectMethod(data.items[childIndex]);
496
+ }
497
+
498
+ /**
499
+ * @description Moves selection within an open submenu.
500
+ * @param {number} num Direction (-1 up, +1 down)
501
+ */
502
+ #moveSubmenuItem(num) {
503
+ const data = this.#submenuData.get(this.#activeSubmenuIndex);
504
+ if (!data?.element) return;
505
+ const items = data.element.querySelectorAll('.se-select-item');
506
+ const len = items.length;
507
+ if (!len) return;
508
+
509
+ num = this.#submenuItemIndex + num;
510
+ const idx = (this.#submenuItemIndex = num >= len ? 0 : num < 0 ? len - 1 : num);
511
+
512
+ dom.utils.removeClass(this.form, 'se-select-menu-mouse-move');
513
+ for (let i = 0; i < len; i++) {
514
+ if (i === idx) dom.utils.addClass(items[i], 'se-select-cursor');
515
+ else dom.utils.removeClass(items[i], 'se-select-cursor');
516
+ }
517
+ }
518
+
200
519
  /**
201
520
  * @description Appends a formatted list of items to the menu.
202
521
  * @param {string} html - The HTML string representing the menu items.
@@ -211,6 +530,7 @@ class SelectMenu {
211
530
  #init() {
212
531
  this.#removeEvents();
213
532
  this.#removeGlobalEvent();
533
+ this.#closeSubmenu();
214
534
  this.index = -1;
215
535
  this.item = null;
216
536
  if (this._onItem) {
@@ -241,9 +561,9 @@ class SelectMenu {
241
561
  const len = this.menuLen;
242
562
  for (let i = 0; i < len; i++) {
243
563
  if (i === selectIndex) {
244
- dom.utils.addClass(this.menus[i], 'active');
564
+ dom.utils.addClass(this.menus[i], 'se-select-cursor');
245
565
  } else {
246
- dom.utils.removeClass(this.menus[i], 'active');
566
+ dom.utils.removeClass(this.menus[i], 'se-select-cursor');
247
567
  }
248
568
  }
249
569
 
@@ -362,31 +682,28 @@ class SelectMenu {
362
682
  }
363
683
 
364
684
  form.style.left = l + 'px';
365
- const fl = this.#$.offset.getGlobal(form).left;
685
+ let fixedLeft = this.#$.offset.getGlobal(form).fixedLeft;
366
686
  let overW = 0;
367
687
  switch (side + '-' + (side ? originP : subPosition)) {
368
688
  case 'true-left':
369
- overW = globalTarget.left - _w.scrollX + fl;
370
- if (overW < 0) l = l = targetL + targetW + 1;
689
+ if (fixedLeft < 0) l = targetL + targetW + 1;
371
690
  break;
372
691
  case 'true-right':
373
- overW = _w.innerWidth - (fl + formW);
374
- if (overW < 0) l = targetL - formW - 1;
692
+ if (fixedLeft + formW > _w.innerWidth) l = targetL - formW - 1;
375
693
  break;
376
- case 'false-center': {
377
- overW = _w.innerWidth - (fl + formW);
694
+ case 'false-center':
695
+ overW = _w.innerWidth - (fixedLeft + formW);
378
696
  if (overW < 0) l += overW - 4;
379
697
  form.style.left = l + 'px';
380
- const centerfl = this.#$.offset.getGlobal(form).left;
381
- if (centerfl < 0) l -= centerfl - 4;
698
+ fixedLeft = this.#$.offset.getGlobal(form).fixedLeft;
699
+ if (fixedLeft < 0) l -= fixedLeft - 4;
382
700
  break;
383
- }
384
701
  case 'false-left':
385
- overW = _w.innerWidth - (globalTarget.left - _w.scrollX + formW);
702
+ overW = _w.innerWidth - (fixedLeft + formW);
386
703
  if (overW < 0) l += overW - 4;
387
704
  break;
388
705
  case 'false-right':
389
- if (fl < 0) l -= fl - 4;
706
+ if (fixedLeft < 0) l -= fixedLeft - 4;
390
707
  break;
391
708
  }
392
709
 
@@ -443,7 +760,11 @@ class SelectMenu {
443
760
  #addGlobalEvent() {
444
761
  this.#removeGlobalEvent();
445
762
  this.#bindClose_key = this.#$.eventManager.addGlobalEvent('keydown', this.#globalEventHandlers.keydown, true);
446
- this.#bindClose_mousedown = this.#$.eventManager.addGlobalEvent('mousedown', this.#globalEventHandlers.mousedown, true);
763
+ this.#bindClose_mousedown = this.#$.eventManager.addGlobalEvent(
764
+ 'mousedown',
765
+ this.#globalEventHandlers.mousedown,
766
+ true,
767
+ );
447
768
  }
448
769
 
449
770
  /**
@@ -459,6 +780,45 @@ class SelectMenu {
459
780
  * @param {KeyboardEvent} e - Event object
460
781
  */
461
782
  #OnKeyDown_refer(e) {
783
+ // submenu keyboard navigation
784
+ if (this.#inSubmenu) {
785
+ switch (e.code) {
786
+ case 'ArrowUp':
787
+ e.preventDefault();
788
+ e.stopPropagation();
789
+ this.#moveSubmenuItem(-1);
790
+ return;
791
+ case 'ArrowDown':
792
+ e.preventDefault();
793
+ e.stopPropagation();
794
+ this.#moveSubmenuItem(1);
795
+ return;
796
+ case 'ArrowLeft':
797
+ e.preventDefault();
798
+ 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
+ }
810
+ return;
811
+ case 'Enter':
812
+ case 'Space':
813
+ if (this.#submenuItemIndex > -1) {
814
+ e.preventDefault();
815
+ e.stopPropagation();
816
+ this.#selectChild(this.#activeSubmenuIndex, this.#submenuItemIndex);
817
+ }
818
+ return;
819
+ }
820
+ }
821
+
462
822
  let moveIndex;
463
823
  switch (e.code) {
464
824
  case 'ArrowUp': // up
@@ -486,9 +846,15 @@ class SelectMenu {
486
846
  e.stopPropagation();
487
847
  moveIndex = -1;
488
848
  break;
489
- case 'ArrowRight': //right
849
+ case 'ArrowRight': // right — enter submenu if available
490
850
  e.preventDefault();
491
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);
856
+ return;
857
+ }
492
858
  moveIndex = 1;
493
859
  break;
494
860
  case 'Enter':
@@ -496,14 +862,21 @@ class SelectMenu {
496
862
  if (this.index > -1) {
497
863
  e.preventDefault();
498
864
  e.stopPropagation();
499
- this.#select(this.index);
500
- } else {
501
- this.close();
865
+ if (this.#submenuData.has(this.index)) {
866
+ this.#openSubmenu(this.index);
867
+ this.#inSubmenu = true;
868
+ this.#moveSubmenuItem(1);
869
+ } else {
870
+ this.#select(this.index);
871
+ }
502
872
  }
503
873
  break;
504
874
  }
505
875
 
506
- if (moveIndex) this.#moveItem(moveIndex);
876
+ if (moveIndex) {
877
+ this.#closeSubmenu();
878
+ this.#moveItem(moveIndex);
879
+ }
507
880
  }
508
881
 
509
882
  /**
@@ -523,9 +896,40 @@ class SelectMenu {
523
896
  #OnMouseMove_list(e) {
524
897
  const eventTarget = dom.query.getEventTarget(e);
525
898
  dom.utils.addClass(this.form, 'se-select-menu-mouse-move');
526
- const index = eventTarget.getAttribute('data-index');
899
+
900
+ // `data-*` attrs live on `<li>`, but events may originate from nested children
901
+ // (e.g. CommandMenu wraps row content in a `<button>` for native :disabled). Walk
902
+ // up so the lookup still works when the target isn't the `<li>` itself.
903
+ const childTarget = eventTarget.closest?.('[data-child-index]');
904
+ const childIndex = childTarget?.getAttribute('data-child-index');
905
+ if (childIndex !== null && childIndex !== undefined) {
906
+ this.#submenuItemIndex = Number(childIndex);
907
+ const subData = this.#submenuData.get(this.#activeSubmenuIndex);
908
+ if (subData?.element) {
909
+ const items = subData.element.querySelectorAll('.se-select-item');
910
+ dom.utils.removeClass(items, 'se-select-cursor');
911
+ dom.utils.addClass(items[this.#submenuItemIndex], 'se-select-cursor');
912
+ }
913
+ return;
914
+ }
915
+
916
+ const indexTarget = eventTarget.closest?.('[data-index]');
917
+ const index = indexTarget?.getAttribute('data-index');
527
918
  if (!index) return;
528
- this.index = Number(index);
919
+ const numIndex = Number(index);
920
+ this.index = numIndex;
921
+
922
+ // submenu hover logic
923
+ if (this.#submenuData.has(numIndex)) {
924
+ if (this.#submenuHoverTimer) _w.clearTimeout(this.#submenuHoverTimer);
925
+ this.#openSubmenu(numIndex);
926
+ } else if (this.#activeSubmenuIndex > -1) {
927
+ // check if mouse is inside the active submenu
928
+ const activeData = this.#submenuData.get(this.#activeSubmenuIndex);
929
+ if (!activeData?.element?.contains(eventTarget)) {
930
+ this.#closeSubmenu();
931
+ }
932
+ }
529
933
  }
530
934
 
531
935
  /**
@@ -534,14 +938,37 @@ class SelectMenu {
534
938
  #OnClick_list(e) {
535
939
  let target = dom.query.getEventTarget(e);
536
940
  let index = null;
941
+ let childIndex = null;
537
942
 
538
- while (!index && !/UL/i.test(target.tagName) && !dom.utils.hasClass(target, 'se-select-menu')) {
539
- index = target.getAttribute('data-index');
943
+ while (!index && !childIndex && !/UL/i.test(target.tagName) && !dom.utils.hasClass(target, 'se-select-menu')) {
944
+ childIndex = target.getAttribute('data-child-index');
945
+ if (!childIndex) index = target.getAttribute('data-index');
540
946
  target = target.parentElement;
541
947
  }
542
948
 
949
+ // child item click
950
+ if (childIndex !== null) {
951
+ let parentTarget = target;
952
+ let parentIndex = null;
953
+ while (!parentIndex && parentTarget) {
954
+ parentIndex = parentTarget.getAttribute('data-parent-index') || parentTarget.getAttribute('data-index');
955
+ parentTarget = parentTarget.parentElement;
956
+ }
957
+ if (parentIndex !== null) this.#selectChild(Number(parentIndex), Number(childIndex));
958
+ return;
959
+ }
960
+
543
961
  if (!index) return;
544
- this.#select(Number(index));
962
+ const numIndex = Number(index);
963
+
964
+ // parent with children — toggle submenu instead of selecting
965
+ if (this.#submenuData.has(numIndex)) {
966
+ if (this.#activeSubmenuIndex === numIndex) this.#closeSubmenu();
967
+ else this.#openSubmenu(numIndex);
968
+ return;
969
+ }
970
+
971
+ this.#select(numIndex);
545
972
  }
546
973
 
547
974
  /**
@@ -549,6 +976,24 @@ class SelectMenu {
549
976
  */
550
977
  #CloseListener_key(e) {
551
978
  if (!keyCodeMap.isEsc(e.code)) return;
979
+
980
+ if (this.#activeSubmenuIndex > -1) {
981
+ e.preventDefault();
982
+ e.stopPropagation();
983
+
984
+ const parentIndex = this.#activeSubmenuIndex;
985
+ this.#closeSubmenu();
986
+ this.#selectItem(parentIndex);
987
+
988
+ return;
989
+ }
990
+
991
+ if (this.subEscMethod?.()) {
992
+ e.preventDefault();
993
+ e.stopPropagation();
994
+ return;
995
+ }
996
+
552
997
  this.close();
553
998
  }
554
999
 
@@ -2,5 +2,6 @@
2
2
  // UI Modules - Helper classes, no callbacks required
3
3
  // ============================================================
4
4
  export { default as _DragHandle } from './_DragHandle.js';
5
+ export { default as CommandMenu } from './CommandMenu.js';
5
6
  export { default as ModalAnchorEditor } from './ModalAnchorEditor.js';
6
7
  export { default as SelectMenu } from './SelectMenu.js';