suneditor 3.1.4 → 3.2.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 (217) 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 +481 -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 +162 -30
  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 +84 -2
  22. package/src/core/event/handlers/handler_ww_key.js +82 -21
  23. package/src/core/event/handlers/handler_ww_mouse.js +45 -8
  24. package/src/core/event/ports.js +27 -13
  25. package/src/core/event/reducers/keydown.reducer.js +33 -5
  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 -106
  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 +13 -3
  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 +11 -4
  173. package/types/core/event/reducers/keydown.reducer.d.ts +12 -3
  174. package/types/core/event/rules/keydown.rule.backspace.d.ts +6 -1
  175. package/types/core/kernel/store.d.ts +4 -1
  176. package/types/core/logic/dom/format.d.ts +32 -14
  177. package/types/core/logic/dom/html.d.ts +11 -3
  178. package/types/core/logic/dom/nodeTransform.d.ts +6 -6
  179. package/types/core/logic/panel/blockHandle.d.ts +64 -0
  180. package/types/core/logic/panel/blockResolver.d.ts +50 -0
  181. package/types/core/logic/panel/menu.d.ts +72 -3
  182. package/types/core/logic/shell/commandDispatcher.d.ts +8 -0
  183. package/types/core/logic/shell/component.d.ts +5 -3
  184. package/types/core/logic/shell/shortcuts.d.ts +10 -1
  185. package/types/core/logic/shell/ui.d.ts +7 -1
  186. package/types/core/schema/context.d.ts +7 -1
  187. package/types/core/schema/frameContext.d.ts +36 -4
  188. package/types/core/schema/options.d.ts +83 -2
  189. package/types/core/section/constructor.d.ts +38 -1
  190. package/types/events.d.ts +208 -32
  191. package/types/helper/converter.d.ts +4 -2
  192. package/types/helper/dom/domCheck.d.ts +12 -2
  193. package/types/helper/dom/domQuery.d.ts +35 -7
  194. package/types/helper/dom/domUtils.d.ts +4 -1
  195. package/types/interfaces/plugins.d.ts +2 -2
  196. package/types/langs/_Lang.d.ts +0 -1
  197. package/types/modules/contract/ColorPicker.d.ts +2 -2
  198. package/types/modules/contract/Controller.d.ts +7 -0
  199. package/types/modules/contract/Figure.d.ts +31 -4
  200. package/types/modules/ui/CommandMenu.d.ts +262 -0
  201. package/types/modules/ui/SelectMenu.d.ts +56 -3
  202. package/types/modules/ui/index.d.ts +1 -0
  203. package/types/plugins/browser/audioGallery.d.ts +1 -1
  204. package/types/plugins/browser/fileBrowser.d.ts +1 -1
  205. package/types/plugins/browser/fileGallery.d.ts +1 -1
  206. package/types/plugins/browser/imageGallery.d.ts +1 -1
  207. package/types/plugins/browser/videoGallery.d.ts +1 -1
  208. package/types/plugins/dropdown/align.d.ts +1 -1
  209. package/types/plugins/dropdown/blockStyle.d.ts +3 -1
  210. package/types/plugins/dropdown/table/index.d.ts +4 -1
  211. package/types/plugins/dropdown/table/services/table.clipboard.d.ts +5 -1
  212. package/types/plugins/dropdown/table/services/table.grid.d.ts +10 -2
  213. package/types/plugins/field/autocomplete.d.ts +0 -1
  214. package/types/plugins/field/slashCommand.d.ts +165 -0
  215. package/types/plugins/index.d.ts +3 -0
  216. package/types/plugins/modal/link.d.ts +4 -1
  217. package/types/typedef.d.ts +48 -23
@@ -0,0 +1,597 @@
1
+ import { dom, env } from '../../helper';
2
+ import SelectMenu from './SelectMenu.js';
3
+
4
+ const { _w } = env;
5
+
6
+ /**
7
+ * @description Resolve an icon spec to an HTML string. Accepts a key from `$.icons`,
8
+ * a raw HTML snippet (anything starting with `<`), or empty.
9
+ * @param {string} icon
10
+ * @param {Object} icons
11
+ * @returns {string}
12
+ */
13
+ export function resolveIconHTML(icon, icons) {
14
+ if (!icon || typeof icon !== 'string') return '';
15
+ if (icon.charAt(0) === '<') return icon;
16
+ return icons[icon] || '';
17
+ }
18
+
19
+ /**
20
+ * @description Build the canonical command-menu row HTML. Single source of truth for the
21
+ * BlockHandle action menu and the SlashCommand menu, so they look identical.
22
+ * @param {string} label
23
+ * @param {string} iconHTML
24
+ * @returns {string}
25
+ */
26
+ export function buildRowHTML(label, iconHTML) {
27
+ const inner = iconHTML
28
+ ? `<span class="se-block-menu-icon">${iconHTML}</span><span class="se-block-menu-label">${label}</span>`
29
+ : `<span class="se-block-menu-label se-block-menu-label-full">${label}</span>`;
30
+ return `<button type="button" class="se-cmd-row">${inner}</button>`;
31
+ }
32
+
33
+ /**
34
+ * @typedef {Object} CommandMenuItem
35
+ * @property {string} [key] - Optional identifier.
36
+ * @property {string} title - Display label.
37
+ * @property {string} [icon] - `$.icons` key or raw HTML snippet.
38
+ * @property {string[]} [keywords] - Extra search terms (used by the filter).
39
+ * @property {function(SunEditor.Deps, *): void | Promise<void>} action - Custom callback. The second
40
+ * argument is the host-supplied context (e.g. `{block}` for BlockHandle, `{triggerChar, query}` for SlashCommand).
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} SubmenuChild
45
+ * @property {string} pluginName - Owning plugin key.
46
+ * @property {HTMLElement} element - The original toolbar dropdown button element. Passed to `plugin.action(element)` on click.
47
+ */
48
+
49
+ /**
50
+ * @typedef {Object} ResolvedItem
51
+ * @property {'custom'|'plugin'|'submenu'|'dropdownFree'} kind
52
+ * @property {string} title
53
+ * @property {string} iconHTML
54
+ * @property {string} search - Lower-cased haystack for substring filtering.
55
+ * @property {CommandMenuItem} [raw]
56
+ * @property {string} [name] - Plugin key.
57
+ * @property {string} [type] - Plugin type (`'modal'`, `'command'`, etc.).
58
+ * @property {string} [command]
59
+ * @property {SubmenuChild[]} [children] - For `kind: 'submenu'` — clickable child rows (one per dropdown item).
60
+ * @property {string[]} [childMenus] - For `kind: 'submenu'` — HTML for each child row.
61
+ */
62
+
63
+ /**
64
+ * @typedef {Object} CommandMenuParams
65
+ * @property {Array<string | CommandMenuItem>} items - Raw menu entries.
66
+ * @property {function(string, Object, *, Object, Object): ({title: string, icon: string, type: string, command: string} | null)} resolveButton
67
+ * Caller-provided plugin/button resolver. Inject `ResolveButton` from `core/section/constructor`.
68
+ * Injected (not imported) because this module lives under `src/modules/` and cannot import from `src/core/*`.
69
+ * @property {Object} selectMenuParams - Base SelectMenu params (`position`, `minWidth`, `keydownTarget`, etc.).
70
+ * @property {function(CommandMenuItem, { icons: Object }): string} [renderCustomItem] - Optional renderer
71
+ * applied to custom (object) items only. Plugin-string items always render with `buildRowHTML`.
72
+ */
73
+
74
+ /**
75
+ * @class
76
+ * @description Shared command-menu module used by BlockHandle and SlashCommand.
77
+ * - Owns the underlying `SelectMenu`, renders rows with the canonical BlockHandle row HTML.
78
+ * - Supports four item kinds:
79
+ * - `'custom'` — user-defined `{title, icon?, action}` invoked with `($, hostContext)`.
80
+ * - `'plugin'` — plain plugin (modal/browser/command/popup) dispatched via `plugin.open/show/action`.
81
+ * - `'submenu'` — dropdown plugin auto-expanded inline (children come from `$.menu.itemsMap`).
82
+ * - `'dropdownFree'` — dropdown-free plugin (e.g. `fontColor`, `table`) shown with a submenu arrow
83
+ * and an attached hover flyout that reuses the plugin's toolbar dropdown DOM.
84
+ * - Handles blur prevention on mousedown, hover flyout lifecycle, and click-to-toggle for dropdown-free.
85
+ *
86
+ * Host responsibilities (kept outside this module):
87
+ * - Pre-dispatch side effects (BlockHandle: `expandRangeToFullLines`; SlashCommand: delete trigger text).
88
+ * - Anchor element selection (BlockHandle anchors to the drag button; SlashCommand to a Controller wrapper).
89
+ * - Context object for custom-item actions.
90
+ */
91
+ class CommandMenu {
92
+ #$;
93
+ #resolveButton;
94
+ #rawItems;
95
+ #resolved = null;
96
+ #renderCustomItem;
97
+
98
+ /** @type {Map<number, { name: string, plugin: any, li: HTMLElement }>} */
99
+ #freeMap = new Map();
100
+ /** @type {?{ dropdown: HTMLElement, plugin: any, originalParent: ?Node, anchorLi: HTMLElement, evClick: ?SunEditor.Event.Info }} */
101
+ #flyoutState = null;
102
+
103
+ /**
104
+ * @type {Array<{ name: string, idx: number }>}
105
+ */
106
+ #pendingTargets = [];
107
+
108
+ /**
109
+ * @type {Array<{ name: string, target: HTMLElement }>}
110
+ */
111
+ #registered = [];
112
+
113
+ /** @type {SelectMenu} */
114
+ selectMenu;
115
+
116
+ /**
117
+ * @constructor
118
+ * @param {*} _host - Reserved for future host-aware behavior (kept for API symmetry with `Modal`/`Controller`).
119
+ * @param {SunEditor.Deps} $ - Deps bag.
120
+ * @param {CommandMenuParams} params
121
+ */
122
+ constructor(_host, $, params) {
123
+ this.#$ = $;
124
+ this.#resolveButton = params.resolveButton;
125
+ this.#rawItems = Array.isArray(params.items) ? params.items : [];
126
+ this.#renderCustomItem = typeof params.renderCustomItem === 'function' ? params.renderCustomItem : null;
127
+
128
+ // Wrap the host's closeMethod so the flyout is always torn down with the menu.
129
+ const userClose = params.selectMenuParams?.closeMethod;
130
+ this.selectMenu = new SelectMenu($, {
131
+ ...params.selectMenuParams,
132
+ closeMethod: () => {
133
+ this.#closeFlyout();
134
+ this.#unregisterAll();
135
+ userClose?.();
136
+ },
137
+ subEscMethod: () => {
138
+ if (!this.#flyoutState) return false;
139
+ const anchorLi = this.#flyoutState.anchorLi;
140
+ this.#closeFlyout();
141
+ const idx = anchorLi ? Number(anchorLi.getAttribute('data-index')) : NaN;
142
+ if (!Number.isNaN(idx)) this.selectMenu.setItem(idx);
143
+ return true;
144
+ },
145
+ });
146
+ }
147
+
148
+ /**
149
+ * @description Bind the menu to a reference element and register the click handler.
150
+ * @param {HTMLElement} referElement
151
+ * @param {(item: ResolvedItem|null) => void} onSelect - Called with the resolved item picked by the user.
152
+ * Hosts run their pre-dispatch hook here (delete trigger, etc.) and then call `dispatch(item, ctx)`.
153
+ * @param {{ class?: string, style?: string }} [attr]
154
+ */
155
+ attach(referElement, onSelect, attr = {}) {
156
+ this.selectMenu.on(referElement, /** @type {*} */ (onSelect), attr);
157
+
158
+ this.#$.eventManager.addEvent(this.selectMenu.form, 'mousedown', (e) => {
159
+ if (env.isMobile) {
160
+ this.#$.store.set('_preventBlur', true);
161
+ } else {
162
+ e.preventDefault();
163
+ }
164
+ });
165
+
166
+ this.#$.eventManager.addEvent(this.selectMenu.form, 'mousemove', this.#onMenuMouseMove.bind(this));
167
+ }
168
+
169
+ /**
170
+ * @description Replace the items list (e.g. when host options change). Invalidates the resolved cache.
171
+ * @param {Array<string | CommandMenuItem>} items
172
+ */
173
+ setItems(items) {
174
+ this.#rawItems = Array.isArray(items) ? items : [];
175
+ this.#resolved = null;
176
+ }
177
+
178
+ /**
179
+ * @description Lazily normalize raw items. Lazy because plugin instances (`$.plugins`) may not
180
+ * be available during the host's constructor.
181
+ * @returns {ResolvedItem[]}
182
+ */
183
+ getItems() {
184
+ if (this.#resolved) return this.#resolved;
185
+ /** @type {ResolvedItem[]} */
186
+ const out = [];
187
+ for (const entry of this.#rawItems) {
188
+ if (typeof entry === 'string') {
189
+ const r = this.#resolveButton(entry, this.#$.plugins, this.#$.options, this.#$.icons, this.#$.lang);
190
+ if (!r) continue;
191
+
192
+ const type = r.type || '';
193
+
194
+ // Dropdown-free
195
+ if (/dropdown-free/.test(type)) {
196
+ out.push({
197
+ kind: 'dropdownFree',
198
+ title: r.title,
199
+ iconHTML: r.icon || '',
200
+ search: (r.title + ' ' + entry).toLowerCase(),
201
+ name: entry,
202
+ type,
203
+ });
204
+ continue;
205
+ }
206
+
207
+ // Dropdown
208
+ if (/dropdown/.test(type)) {
209
+ const menuItems = this.#$.menu?.itemsMap?.[entry] || [];
210
+ if (menuItems.length > 0) {
211
+ out.push({
212
+ kind: 'submenu',
213
+ title: r.title,
214
+ iconHTML: r.icon || '',
215
+ search: (r.title + ' ' + entry).toLowerCase(),
216
+ name: entry,
217
+ children: menuItems.map((mi) => ({ pluginName: entry, element: mi._element })),
218
+ childMenus: menuItems.map((mi) => {
219
+ const iconEl =
220
+ mi._element?.querySelector('.se-list-icon') || mi._element?.querySelector('svg');
221
+ const icon = iconEl ? iconEl.outerHTML : '';
222
+ const label = icon ? `${icon}<span>${mi.title}</span>` : mi.title;
223
+ return `<span class="se-block-submenu-item">${label}</span>`;
224
+ }),
225
+ });
226
+ continue;
227
+ }
228
+ }
229
+
230
+ out.push({
231
+ kind: 'plugin',
232
+ title: r.title,
233
+ iconHTML: r.icon || '',
234
+ search: (r.title + ' ' + entry).toLowerCase(),
235
+ name: entry,
236
+ type,
237
+ command: r.command,
238
+ });
239
+ } else if (entry && typeof entry === 'object' && typeof entry.action === 'function') {
240
+ const title = entry.title || entry.key || '';
241
+ const keywords = Array.isArray(entry.keywords) ? entry.keywords.join(' ') : '';
242
+ out.push({
243
+ kind: 'custom',
244
+ title,
245
+ iconHTML: resolveIconHTML(entry.icon, this.#$.icons),
246
+ search: (title + ' ' + keywords).toLowerCase(),
247
+ raw: entry,
248
+ });
249
+ }
250
+ }
251
+ this.#resolved = out;
252
+ return out;
253
+ }
254
+
255
+ /**
256
+ * @description Case-insensitive substring filter on `title + keywords + plugin name`.
257
+ * Empty query returns all items (up to `limit`).
258
+ * @param {string} query
259
+ * @param {number} [limit=Infinity]
260
+ * @returns {ResolvedItem[]}
261
+ */
262
+ filter(query, limit = Infinity) {
263
+ const items = this.getItems();
264
+ const q = (query || '').toLowerCase().trim();
265
+ const out = [];
266
+ for (const it of items) {
267
+ if (!q || it.search.indexOf(q) !== -1) out.push(it);
268
+ if (out.length >= limit) break;
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /**
274
+ * @description Build row HTML for a list of resolved items.
275
+ * @param {ResolvedItem[]} items
276
+ * @returns {string[]}
277
+ */
278
+ renderRows(items) {
279
+ const ctx = { icons: this.#$.icons };
280
+ const arrow = `<span class="se-submenu-arrow">${this.#$.icons.menu_arrow_right}</span>`;
281
+ return items.map((it) => {
282
+ if (it.kind === 'custom' && this.#renderCustomItem) return this.#renderCustomItem(it.raw, ctx);
283
+ const row = buildRowHTML(it.title, it.iconHTML);
284
+ return it.kind === 'dropdownFree' ? row + arrow : row;
285
+ });
286
+ }
287
+
288
+ /**
289
+ * @description Populate the SelectMenu rows.
290
+ * @param {ResolvedItem[]} items
291
+ */
292
+ createRows(items) {
293
+ // SlashCommand rebuilds rows on every keystroke; previous `li`s become detached. Drop their
294
+ // registrations before the SelectMenu rewrite so toolbar map never points at orphaned nodes.
295
+ this.#unregisterAll();
296
+
297
+ const selectItems = items.map((it) =>
298
+ it.kind === 'submenu' ? { children: it.children, childMenus: it.childMenus } : it,
299
+ );
300
+ this.selectMenu.create(selectItems, this.renderRows(items));
301
+
302
+ this.#freeMap.clear();
303
+ this.#pendingTargets.length = 0;
304
+ items.forEach((it, i) => {
305
+ if (it.kind === 'dropdownFree') {
306
+ this.#freeMap.set(i, {
307
+ name: it.name,
308
+ plugin: this.#$.plugins[it.name],
309
+ li: this.selectMenu.menus[i],
310
+ });
311
+ }
312
+ if (it.kind === 'plugin' || it.kind === 'dropdownFree') {
313
+ this.#pendingTargets.push({ name: it.name, idx: i });
314
+ }
315
+ });
316
+
317
+ // SlashCommand stays "open" across createRows calls — re-register immediately so the freshly
318
+ // built `li` set takes over from the discarded one without waiting for the next open().
319
+ if (this.selectMenu.isOpen) this.#registerAll();
320
+ }
321
+
322
+ /**
323
+ * @description Open the SelectMenu. Lazily (re-)registers menu rows into
324
+ * `commandDispatcher.targets` so `selectionState.update` paints the `active` class on them through
325
+ * @param {string} [position]
326
+ */
327
+ open(position) {
328
+ this.selectMenu.open(position);
329
+ if (this.#registered.length === 0) this.#registerAll();
330
+ }
331
+
332
+ /**
333
+ * @description Close the SelectMenu. Also unregisters menu rows from `commandDispatcher.targets`
334
+ */
335
+ close() {
336
+ this.#unregisterAll();
337
+ this.selectMenu.close();
338
+ }
339
+
340
+ /**
341
+ * @description Whether a sub-panel (native submenu or dropdown-free flyout) is currently open.
342
+ * Lets an owning Controller keep the menu open on ESC and close only the sub-panel.
343
+ * @returns {boolean}
344
+ */
345
+ hasOpenSubPanel() {
346
+ return this.selectMenu.hasOpenSubmenu() || !!this.#flyoutState;
347
+ }
348
+
349
+ /**
350
+ * @description Push every `#pendingTargets` row's inner `<button>` into the toolbar target map
351
+ */
352
+ #registerAll() {
353
+ const cd = this.#$.commandDispatcher;
354
+ for (const { name, idx } of this.#pendingTargets) {
355
+ const btn = this.#resolveButtonTarget(idx);
356
+ if (!btn) continue;
357
+ cd.registerTargets(name, btn);
358
+ this.#registered.push({ name, target: btn });
359
+ }
360
+ // Bust the dedup cache so applyTagEffect re-runs even when the selection node is unchanged.
361
+ this.#$.store.set('_lastSelectionNode', null);
362
+ this.#$.eventManager.applyTagEffect();
363
+ }
364
+
365
+ /**
366
+ * @description Drop every `(cmd, target)` pair this instance registered. Safe to call repeatedly.
367
+ */
368
+ #unregisterAll() {
369
+ if (this.#registered.length === 0) return;
370
+ const cd = this.#$.commandDispatcher;
371
+ for (const { name, target } of this.#registered) cd.unregisterTargets(name, target);
372
+ this.#registered.length = 0;
373
+ }
374
+
375
+ /**
376
+ * @description Resolve the registration target for menu index `idx`.
377
+ * (defensive — should always exist after {@link buildRowHTML}).
378
+ * @param {number} idx
379
+ * @returns {?HTMLButtonElement}
380
+ */
381
+ #resolveButtonTarget(idx) {
382
+ const li = this.selectMenu.menus[idx];
383
+ if (!li) return null;
384
+ return /** @type {?HTMLButtonElement} */ (li.querySelector('button.se-cmd-row') || li);
385
+ }
386
+
387
+ /**
388
+ * @description Highlight a row.
389
+ * @param {number} index
390
+ */
391
+ setItem(index) {
392
+ this.selectMenu.setItem(index);
393
+ }
394
+
395
+ /**
396
+ * @description Soft-hide / soft-show without changing open state. Pass-through.
397
+ * @param {boolean} hidden
398
+ */
399
+ setHidden(hidden) {
400
+ this.selectMenu.setHidden(hidden);
401
+ }
402
+
403
+ get isOpen() {
404
+ return this.selectMenu.isOpen;
405
+ }
406
+
407
+ /**
408
+ * @description Dispatch a resolved item. For custom items, calls `raw.action($, ctx)`. For plugin
409
+ * items, routes to `plugin.open/show/action` or `commandDispatcher.run` (built-ins). For dropdown-free
410
+ * items, toggles the hover flyout
411
+ * @param {ResolvedItem | SubmenuChild | null} item
412
+ * @param {*} [ctx] - Context passed to custom-item `action` (host-specific shape).
413
+ * @returns {boolean} `true` if a plugin was dispatched
414
+ */
415
+ dispatch(item, ctx) {
416
+ if (!item) return false;
417
+ const asChild = /** @type {SubmenuChild} */ (/** @type {unknown} */ (item));
418
+ if (asChild.pluginName && asChild.element) {
419
+ const plugin = this.#$.plugins[asChild.pluginName];
420
+ plugin?.action?.(asChild.element);
421
+ this.#$.history.push(false);
422
+ return true;
423
+ }
424
+ const resolved = /** @type {ResolvedItem} */ (item);
425
+ if (resolved.kind === 'custom') {
426
+ try {
427
+ resolved.raw.action(this.#$, ctx);
428
+ } catch (e) {
429
+ console.error('[SUNEDITOR.commandMenu.customAction]', e);
430
+ }
431
+ return false;
432
+ }
433
+ if (resolved.kind === 'dropdownFree') {
434
+ const plugin = this.#$.plugins[resolved.name];
435
+ if (!plugin) return false;
436
+ if (this.#flyoutState?.plugin === plugin) {
437
+ this.#closeFlyout();
438
+ return false;
439
+ }
440
+ const idx = this.selectMenu.items.indexOf(/** @type {*} */ (resolved));
441
+ const anchorLi = idx >= 0 ? this.selectMenu.menus[idx] : null;
442
+ if (anchorLi) this.#openFlyout(resolved.name, plugin, anchorLi);
443
+ return false;
444
+ }
445
+ const plugin = resolved.name ? this.#$.plugins[resolved.name] : null;
446
+ if (plugin) {
447
+ const type = resolved.type || '';
448
+ if (/modal/.test(type)) plugin.open?.();
449
+ else if (/browser/.test(type)) plugin.open?.(null);
450
+ else if (/popup/.test(type)) plugin.show?.();
451
+ else plugin.action?.(dom.utils.createElement('BUTTON', { 'data-command': resolved.name }));
452
+ this.#$.history.push(false);
453
+ return true;
454
+ }
455
+ if (resolved.command) {
456
+ this.#$.commandDispatcher.run(resolved.command, null, null);
457
+ }
458
+ return false;
459
+ }
460
+
461
+ /**
462
+ * @description Mousemove handler on the menu form. For dropdown-free rows.
463
+ * @param {MouseEvent} e
464
+ */
465
+ #onMenuMouseMove(e) {
466
+ if (this.#freeMap.size === 0) return;
467
+ const target = /** @type {HTMLElement} */ (e.target);
468
+ if (this.#flyoutState?.dropdown?.contains(target)) return;
469
+
470
+ const li = target.closest?.('li[data-index]');
471
+ if (!li) return;
472
+
473
+ const idx = Number(li.getAttribute('data-index'));
474
+ const free = this.#freeMap.get(idx);
475
+ if (free) {
476
+ if (this.#flyoutState?.plugin !== free.plugin) {
477
+ this.#openFlyout(free.name, free.plugin, free.li);
478
+ }
479
+ } else if (this.#flyoutState) {
480
+ this.#closeFlyout();
481
+ }
482
+ }
483
+
484
+ /**
485
+ * @description Open a dropdown-free plugin's flyout.
486
+ * @param {string} name
487
+ * @param {*} plugin
488
+ * @param {HTMLElement} anchorLi
489
+ */
490
+ #openFlyout(name, plugin, anchorLi) {
491
+ if (this.#flyoutState) this.#closeFlyout();
492
+ const dropdown = this.#$.menu?.targetMap?.[name];
493
+ if (!dropdown || !this.selectMenu.form) return;
494
+
495
+ const originalParent = dropdown.parentNode;
496
+ this.selectMenu.form.appendChild(dropdown);
497
+ this.#positionFlyout(dropdown, anchorLi, this.selectMenu.form);
498
+
499
+ dom.utils.addClass(anchorLi, 'se-submenu-open');
500
+ plugin.on?.(anchorLi);
501
+
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
+
511
+ this.#flyoutState = { dropdown, plugin, originalParent, anchorLi, evClick };
512
+ }
513
+
514
+ /**
515
+ * @description Close the open flyout (if any). Restores the dropdown DOM to its original parent
516
+ * (the menuTray) so the toolbar can use it again.
517
+ */
518
+ #closeFlyout() {
519
+ const s = this.#flyoutState;
520
+ if (!s) return;
521
+ this.#flyoutState = null;
522
+
523
+ this.#$?.eventManager.removeEvent(s.evClick);
524
+ s.dropdown.style.cssText = '';
525
+ s.dropdown.style.display = 'none';
526
+
527
+ if (s.anchorLi) dom.utils.removeClass(s.anchorLi, 'se-submenu-open');
528
+
529
+ if (s.originalParent && s.originalParent !== s.dropdown.parentNode) {
530
+ s.originalParent.appendChild(s.dropdown);
531
+ }
532
+
533
+ s.plugin?.off?.();
534
+ }
535
+
536
+ /**
537
+ * @description Place `el` next to `anchor`, coordinates relative to `container`. Mirrors
538
+ * `SelectMenu.#openSubmenu`: prefers the text-direction side, flips on overflow, shifts on
539
+ * double overflow.
540
+ * @param {HTMLElement} el
541
+ * @param {HTMLElement} anchor
542
+ * @param {HTMLElement} container
543
+ */
544
+ #positionFlyout(el, anchor, container) {
545
+ const a = anchor.getBoundingClientRect();
546
+ const c = container.getBoundingClientRect();
547
+ const isRtl = !!this.#$.options.get('_rtl');
548
+
549
+ el.style.position = 'absolute';
550
+ el.style.right = '';
551
+ el.style.visibility = 'hidden';
552
+ el.style.display = 'block';
553
+
554
+ const elW = el.offsetWidth;
555
+ const elH = el.offsetHeight;
556
+ const vpW = _w.innerWidth;
557
+ const vpH = _w.innerHeight;
558
+ const gap = 4;
559
+
560
+ // Horizontal: preferred side follows text direction. Flip if it overflows; if both overflow,
561
+ // keep the smaller-overflow side and shift inward by the missing amount.
562
+ const rightVP = a.right + gap;
563
+ const leftVP = a.left - elW - gap;
564
+ const rightOverflow = Math.max(0, rightVP + elW - vpW);
565
+ const leftOverflow = Math.max(0, -leftVP);
566
+ const prefer = isRtl ? 'left' : 'right';
567
+ let leftPx;
568
+ if (prefer === 'right') {
569
+ if (rightOverflow === 0) leftPx = rightVP;
570
+ else if (leftOverflow === 0) leftPx = leftVP;
571
+ else if (rightOverflow <= leftOverflow) leftPx = rightVP - rightOverflow;
572
+ else leftPx = leftVP + leftOverflow;
573
+ } else {
574
+ if (leftOverflow === 0) leftPx = leftVP;
575
+ else if (rightOverflow === 0) leftPx = rightVP;
576
+ else if (leftOverflow <= rightOverflow) leftPx = leftVP + leftOverflow;
577
+ else leftPx = rightVP - rightOverflow;
578
+ }
579
+ el.style.left = leftPx - c.left + 'px';
580
+
581
+ // Vertical: top-aligned with anchor, flip on overflow.
582
+ const topVP = a.top;
583
+ const bottomVP = a.bottom - elH;
584
+ const downOverflow = Math.max(0, topVP + elH - vpH);
585
+ const upOverflow = Math.max(0, -bottomVP);
586
+ let topPx;
587
+ if (downOverflow === 0) topPx = topVP;
588
+ else if (upOverflow === 0) topPx = bottomVP;
589
+ else if (downOverflow <= upOverflow) topPx = topVP - downOverflow;
590
+ else topPx = bottomVP + upOverflow;
591
+ el.style.top = topPx - c.top + 'px';
592
+
593
+ el.style.visibility = '';
594
+ }
595
+ }
596
+
597
+ export default CommandMenu;
@@ -195,7 +195,10 @@ class ModalAnchorEditor {
195
195
  this.titleInput.value = '';
196
196
  } else if (this.currentTarget) {
197
197
  const href = this.currentTarget.href;
198
- this.linkValue = this.preview.textContent = this.urlInput.value = this.#selfPathBookmark(href) ? href.substring(href.lastIndexOf('#')) : href;
198
+ this.linkValue =
199
+ this.preview.textContent =
200
+ this.urlInput.value =
201
+ this.#selfPathBookmark(href) ? href.substring(href.lastIndexOf('#')) : href;
199
202
  this.displayInput.value = this.currentTarget.textContent;
200
203
  this.titleInput.value = this.currentTarget.title;
201
204
  this.newWindowCheck.checked = /_blank/i.test(this.currentTarget.target) ? true : false;
@@ -296,7 +299,11 @@ class ModalAnchorEditor {
296
299
  */
297
300
  #selfPathBookmark(path) {
298
301
  const href = _w.location.href.replace(/\/$/, '');
299
- return path.indexOf('#') === 0 || (path.indexOf(href) === 0 && path.indexOf('#') === (!href.includes('#') ? href.length : href.substring(0, href.indexOf('#')).length));
302
+ return (
303
+ path.indexOf('#') === 0 ||
304
+ (path.indexOf(href) === 0 &&
305
+ path.indexOf('#') === (!href.includes('#') ? href.length : href.substring(0, href.indexOf('#')).length))
306
+ );
300
307
  }
301
308
 
302
309
  /**
@@ -330,7 +337,11 @@ class ModalAnchorEditor {
330
337
  * @param {string} urlValue - The current URL input value.
331
338
  */
332
339
  #createBookmarkList(urlValue) {
333
- const headers = dom.query.getListChildren(this.#$.frameContext.get('wysiwyg'), (current) => /h[1-6]/i.test(current.nodeName) || (dom.check.isAnchor(current) && !!current.id), null);
340
+ const headers = dom.query.getListChildren(
341
+ this.#$.frameContext.get('wysiwyg'),
342
+ (current) => /h[1-6]/i.test(current.nodeName) || (dom.check.isAnchor(current) && !!current.id),
343
+ null,
344
+ );
334
345
  if (headers.length === 0) return;
335
346
 
336
347
  const valueRegExp = new RegExp(`^${urlValue.replace(/^#/, '')}`, 'i');
@@ -340,7 +351,11 @@ class ModalAnchorEditor {
340
351
  v = headers[i];
341
352
  if (!valueRegExp.test(v.textContent)) continue;
342
353
  list.push(v);
343
- menus.push(dom.check.isAnchor(v) ? `<div><span class="se-text-prefix-icon">${this.#$.icons.bookmark_anchor}</span>${v.id}</div>` : `<div style="${v.style.cssText}">${v.textContent}</div>`);
354
+ menus.push(
355
+ dom.check.isAnchor(v)
356
+ ? `<div><span class="se-text-prefix-icon">${this.#$.icons.bookmark_anchor}</span>${v.id}</div>`
357
+ : `<div style="${v.style.cssText}">${v.textContent}</div>`,
358
+ );
344
359
  }
345
360
 
346
361
  if (list.length === 0) {
@@ -360,12 +375,24 @@ class ModalAnchorEditor {
360
375
  const protocol = this.#$.options.get('defaultUrlProtocol');
361
376
  const noPrefix = this.noAutoPrefix;
362
377
  const reservedProtocol = /^(mailto:|tel:|sms:|https*:\/\/|#)/.test(value) || value.indexOf(protocol) === 0;
363
- const sameProtocol = !protocol ? false : RegExp('^' + unicode.escapeStringRegexp(value.substring(0, protocol.length))).test(protocol);
378
+ const sameProtocol = !protocol
379
+ ? false
380
+ : RegExp('^' + unicode.escapeStringRegexp(value.substring(0, protocol.length))).test(protocol);
364
381
 
365
382
  value =
366
383
  this.linkValue =
367
384
  preview.textContent =
368
- !value ? '' : noPrefix ? value : protocol && !reservedProtocol && !sameProtocol ? protocol + value : reservedProtocol ? value : /^www\./.test(value) ? 'http://' + value : this.host + (/^\//.test(value) ? '' : '/') + value;
385
+ !value
386
+ ? ''
387
+ : noPrefix
388
+ ? value
389
+ : protocol && !reservedProtocol && !sameProtocol
390
+ ? protocol + value
391
+ : reservedProtocol
392
+ ? value
393
+ : /^www\./.test(value)
394
+ ? 'http://' + value
395
+ : this.host + (/^\//.test(value) ? '' : '/') + value;
369
396
 
370
397
  if (this.#selfPathBookmark(value)) {
371
398
  this.bookmark.style.display = 'block';